fluffy-landing / index.html
flausch's picture
Update index.html
87687a9 verified
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
<title>Visual Synth — Psychedelic One-File App</title>
<style>
:root{
--bg:#0a0d12; --panel:#0f1420; --acc:#69f; --acc2:#f0f; --text:#dfe7ff;
--muted:#7d8aad; --glow:0 0 12px rgba(102,153,255,.5);
}
html,body{margin:0;padding:0;background:var(--bg);color:var(--text);font-family:Inter,system-ui,Segoe UI,Arial,sans-serif;height:100%;}
#wrap{position:fixed;inset:0;display:flex;overflow:hidden;}
canvas{position:absolute;inset:0;width:100%;height:100%;display:block;background:#000;}
#ui{
position:relative;z-index:10;width:360px;max-width:80vw;height:100%;
background:linear-gradient(180deg,rgba(10,13,18,.96),rgba(10,13,18,.92));
border-right:1px solid #1c2333; box-shadow:var(--glow); overflow:auto; padding:14px 14px 100px 14px;
}
#title{display:flex;align-items:center;gap:10px;margin-bottom:8px;}
#title h1{font-size:16px;margin:0;letter-spacing:.4px;font-weight:700;}
#status{font-size:11px;color:var(--muted); margin-left:auto}
.btnrow{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0 14px}
button,select{
background:#141a2a;color:#dfe7ff;border:1px solid #25304a;border-radius:8px;padding:8px 10px;cursor:pointer;
transition:.2s; box-shadow:0 0 0 0 rgba(0,0,0,0);
}
button:hover{border-color:#3b4f7a;box-shadow:0 0 0 2px rgba(59,79,122,.15)}
button.primary{background:linear-gradient(180deg,#1a2442,#12192c);border-color:#2a3960}
.section{background:#0f1420;border:1px solid #1b2233;border-radius:12px;margin:8px 0;padding:10px}
.section h3{margin:0 0 8px;font-size:12px;text-transform:uppercase;letter-spacing:.12em;color:#9bb1ff}
.grid{display:grid;grid-template-columns:1fr 64px;gap:6px 10px;align-items:center}
.row{display:flex;gap:8px;align-items:center}
label{font-size:12px;color:#b9c6ff}
.val{font-size:11px;color:#9fb2ff;text-align:right}
input[type=range]{
width:100%; height:26px; background:transparent; -webkit-appearance:none; appearance:none;
}
input[type=range]::-webkit-slider-runnable-track{
height:6px;border-radius:99px;background:linear-gradient(90deg,#263357,#1d2745);border:1px solid #2b3b61;
}
input[type=range]::-webkit-slider-thumb{
-webkit-appearance:none; width:16px;height:16px;border-radius:50%;
background:radial-gradient(circle at 30% 30%,#9cf,#58f);border:1px solid #2b3b61;margin-top:-5px;box-shadow:0 0 0 2px rgba(102,153,255,.15);
}
.small{font-size:11px;color:#8ea0cf}
.split{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.muted{color:#8ea0cf}
.sticky{position:sticky;top:0;background:linear-gradient(180deg,rgba(10,13,18,1),rgba(10,13,18,.9));padding-bottom:8px;margin-bottom:8px;z-index:5}
#footer{position:fixed;left:0;right:0;bottom:0;padding:6px 12px;background:linear-gradient(180deg,rgba(10,13,18,.1),rgba(10,13,18,.7));display:flex;gap:10px;align-items:center;justify-content:space-between;z-index:9;border-top:1px solid #1b2233}
#footer .hint{font-size:11px;color:#8ea0cf}
.kbd{background:#111728;border:1px solid #2b3b61;border-radius:6px;padding:2px 6px;color:#9fb2ff;margin:0 2px}
.badge{background:rgba(108,80,255,.12);border:1px solid rgba(108,80,255,.45);padding:2px 6px;border-radius:6px;color:#c7b9ff;font-size:11px}
.two-col{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.hr{height:1px;background:#1b2233;margin:8px 0}
</style>
</head>
<body>
<div id="wrap">
<canvas id="c"></canvas>
<div id="ui">
<div class="sticky">
<div id="title">
<h1>Visual Synth</h1>
<span class="badge">psychedelic</span>
<div id="status">FPS: <span id="fps"></span></div>
</div>
<div class="btnrow">
<button id="btnFull" title="Fullscreen (F)">Fullscreen</button>
<button id="btnRandom" class="primary" title="Randomize (R)">Randomize</button>
<select id="presetSel" title="Presets (1–6)"></select>
<button id="btnSave">Preset speichern</button>
<button id="btnLoad">Preset laden</button>
<button id="btnRecord" title="Canvas aufnehmen">Aufnahme</button>
</div>
<div class="row">
<button id="btnMic" title="Mikrofon (M)">Mic: Aus</button>
<div class="small muted" id="audioInfo">Audio: –</div>
</div>
</div>
<div class="section">
<h3>Global & Timing</h3>
<div class="grid">
<label for="bpm">BPM</label><div><input id="bpm" type="range" min="40" max="200" step="1"><div class="val" id="bpmVal"></div></div>
<label for="timeScale">Zeit-Skala</label><div><input id="timeScale" type="range" min="0.1" max="3" step="0.01"><div class="val" id="timeScaleVal"></div></div>
<label for="seed">Seed</label><div><input id="seed" type="range" min="0" max="999" step="1"><div class="val" id="seedVal"></div></div>
</div>
</div>
<div class="section">
<h3>Pattern & Warp</h3>
<div class="two-col">
<div>
<label>Pattern A</label>
<select id="p1Type">
<option value="0">Rings</option>
<option value="1">Stripes</option>
<option value="2">Checker</option>
<option value="3">Swirl</option>
<option value="4">FBM</option>
</select>
</div>
<div>
<label>Pattern B</label>
<select id="p2Type">
<option value="0">Rings</option>
<option value="1">Stripes</option>
<option value="2">Checker</option>
<option value="3">Swirl</option>
<option value="4">FBM</option>
</select>
</div>
</div>
<div class="grid">
<label for="p1Scale">A Scale</label><div><input id="p1Scale" type="range" min="0.2" max="8" step="0.01"><div class="val" id="p1ScaleVal"></div></div>
<label for="p2Scale">B Scale</label><div><input id="p2Scale" type="range" min="0.2" max="8" step="0.01"><div class="val" id="p2ScaleVal"></div></div>
<label for="p1Speed">A Speed</label><div><input id="p1Speed" type="range" min="-4" max="4" step="0.01"><div class="val" id="p1SpeedVal"></div></div>
<label for="p2Speed">B Speed</label><div><input id="p2Speed" type="range" min="-4" max="4" step="0.01"><div class="val" id="p2SpeedVal"></div></div>
<label for="mix">Mix</label><div><input id="mix" type="range" min="0" max="1" step="0.001"><div class="val" id="mixVal"></div></div>
<label for="blend">Blend Mode</label>
<div>
<select id="blend">
<option value="0">Linear</option>
<option value="1">Add</option>
<option value="2">Multiply</option>
<option value="3">Screen</option>
<option value="4">Difference</option>
</select>
</div>
<label for="noiseScale">Noise Scale</label><div><input id="noiseScale" type="range" min="0.1" max="8" step="0.01"><div class="val" id="noiseScaleVal"></div></div>
<label for="warpAmount">Warp Amount</label><div><input id="warpAmount" type="range" min="0" max="2" step="0.001"><div class="val" id="warpAmountVal"></div></div>
<label for="warpSpeed">Warp Speed</label><div><input id="warpSpeed" type="range" min="-4" max="4" step="0.01"><div class="val" id="warpSpeedVal"></div></div>
</div>
</div>
<div class="section">
<h3>Kaleidoskop & Transform</h3>
<div class="grid">
<label for="slices">Slices</label><div><input id="slices" type="range" min="1" max="16" step="1"><div class="val" id="slicesVal"></div></div>
<label for="kAngle">Angle</label><div><input id="kAngle" type="range" min="-3.1416" max="3.1416" step="0.001"><div class="val" id="kAngleVal"></div></div>
<label for="mirror">Mirror</label><div><input id="mirror" type="range" min="0" max="1" step="1"><div class="val" id="mirrorVal"></div></div>
<label for="zoom">Zoom</label><div><input id="zoom" type="range" min="0.2" max="3" step="0.001"><div class="val" id="zoomVal"></div></div>
<label for="rotSpeed">Rotate Speed</label><div><input id="rotSpeed" type="range" min="-4" max="4" step="0.01"><div class="val" id="rotSpeedVal"></div></div>
<label for="panX">Pan X</label><div><input id="panX" type="range" min="-1" max="1" step="0.001"><div class="val" id="panXVal"></div></div>
<label for="panY">Pan Y</label><div><input id="panY" type="range" min="-1" max="1" step="0.001"><div class="val" id="panYVal"></div></div>
</div>
</div>
<div class="section">
<h3>Feedback & Post FX</h3>
<div class="grid">
<label for="fbAmt">Feedback Amount</label><div><input id="fbAmt" type="range" min="0" max="1" step="0.001"><div class="val" id="fbAmtVal"></div></div>
<label for="fbZoom">Feedback Zoom</label><div><input id="fbZoom" type="range" min="-0.2" max="0.2" step="0.001"><div class="val" id="fbZoomVal"></div></div>
<label for="fbRot">Feedback Rotate</label><div><input id="fbRot" type="range" min="-2" max="2" step="0.001"><div class="val" id="fbRotVal"></div></div>
<label for="fbBlend">Feedback Blend</label>
<div>
<select id="fbBlend">
<option value="0">Linear</option>
<option value="1">Add</option>
<option value="2">Multiply</option>
<option value="3">Screen</option>
<option value="4">Difference</option>
</select>
</div>
<label for="blur">Blur</label><div><input id="blur" type="range" min="0" max="1" step="0.001"><div class="val" id="blurVal"></div></div>
<label for="chroma">Chromatic Aberration</label><div><input id="chroma" type="range" min="0" max="1" step="0.001"><div class="val" id="chromaVal"></div></div>
<label for="pixel">Pixelation</label><div><input id="pixel" type="range" min="0" max="1" step="0.001"><div class="val" id="pixelVal"></div></div>
<label for="vign">Vignette</label><div><input id="vign" type="range" min="0" max="1" step="0.001"><div class="val" id="vignVal"></div></div>
<label for="strobe">Strobe Speed</label><div><input id="strobe" type="range" min="0" max="20" step="0.01"><div class="val" id="strobeVal"></div></div>
<label for="strobeDuty">Strobe Duty</label><div><input id="strobeDuty" type="range" min="0.02" max="0.98" step="0.01"><div class="val" id="strobeDutyVal"></div></div>
</div>
</div>
<div class="section">
<h3>Farbe</h3>
<div class="grid">
<label>Palette</label>
<div><select id="palette">
<option value="0">Rainbow</option>
<option value="1">Neon</option>
<option value="2">Cyberpunk</option>
<option value="3">Heat</option>
<option value="4">Cool</option>
</select></div>
<label for="hueShift">Hue Shift</label><div><input id="hueShift" type="range" min="0" max="1" step="0.001"><div class="val" id="hueShiftVal"></div></div>
<label for="hueSpeed">Hue Speed</label><div><input id="hueSpeed" type="range" min="-1" max="1" step="0.001"><div class="val" id="hueSpeedVal"></div></div>
<label for="saturation">Saturation</label><div><input id="saturation" type="range" min="0" max="2" step="0.001"><div class="val" id="saturationVal"></div></div>
<label for="contrast">Contrast</label><div><input id="contrast" type="range" min="0.2" max="3" step="0.001"><div class="val" id="contrastVal"></div></div>
<label for="brightness">Brightness</label><div><input id="brightness" type="range" min="0" max="3" step="0.001"><div class="val" id="brightnessVal"></div></div>
<label for="gamma">Gamma</label><div><input id="gamma" type="range" min="0.2" max="3" step="0.001"><div class="val" id="gammaVal"></div></div>
</div>
</div>
<div class="section">
<h3>LFOs (Modulation)</h3>
<div class="small muted">Jeder LFO kann 2 Ziele modulieren. Depth ist bipolar (−/+).</div>
<div id="lfos"></div>
</div>
<div class="section">
<h3>Audio-Reaktiv</h3>
<div class="grid">
<label for="audioAmt">Global Amount</label><div><input id="audioAmt" type="range" min="0" max="2" step="0.001"><div class="val" id="audioAmtVal"></div></div>
<label for="audioHue">→ Hue</label><div><input id="audioHue" type="range" min="0" max="1" step="0.001"><div class="val" id="audioHueVal"></div></div>
<label for="audioZoom">→ Zoom</label><div><input id="audioZoom" type="range" min="0" max="1" step="0.001"><div class="val" id="audioZoomVal"></div></div>
<label for="audioWarp">→ Warp</label><div><input id="audioWarp" type="range" min="0" max="1" step="0.001"><div class="val" id="audioWarpVal"></div></div>
<label for="audioSlices">→ Slices</label><div><input id="audioSlices" type="range" min="0" max="1" step="0.001"><div class="val" id="audioSlicesVal"></div></div>
<label for="audioSmooth">Smoothing</label><div><input id="audioSmooth" type="range" min="0" max="0.99" step="0.001"><div class="val" id="audioSmoothVal"></div></div>
</div>
</div>
<div class="section">
<h3>Presets & JSON</h3>
<div class="small muted">Du kannst die aktuellen Einstellungen als JSON speichern/laden.</div>
<textarea id="json" rows="6" style="width:100%;background:#0b1020;color:#dfe7ff;border:1px solid #1b2233;border-radius:8px;padding:8px"></textarea>
<div class="btnrow">
<button id="btnToJSON">→ JSON</button>
<button id="btnFromJSON">← Aus JSON</button>
</div>
</div>
</div>
</div>
<div id="footer">
<div class="hint">Shortcuts: <span class="kbd">F</span> Fullscreen • <span class="kbd">R</span> Random • <span class="kbd">1–6</span> Presets • <span class="kbd">M</span> Mic</div>
<div class="hint">Made for trippy visuals 🎨🔮</div>
</div>
<script>
;(() => {
"use strict";
const canvas = document.getElementById('c');
const gl = canvas.getContext('webgl', {antialias:false, depth:false, stencil:false, premultipliedAlpha:false, preserveDrawingBuffer:true});
if(!gl){ alert('WebGL wird benötigt.'); return; }
// Shaders
const vertSrc = `
attribute vec2 a_pos;
varying vec2 v_uv;
void main(){
v_uv = a_pos*0.5 + 0.5;
gl_Position = vec4(a_pos,0.0,1.0);
}`;
const fragSrc = `
precision mediump float;
varying vec2 v_uv;
uniform vec2 u_res;
uniform float u_time;
uniform float u_bpm;
uniform float u_seed;
uniform sampler2D u_prev;
// Pattern uniforms
uniform float u_p1Type;
uniform float u_p2Type;
uniform float u_p1Scale;
uniform float u_p2Scale;
uniform float u_p1Speed;
uniform float u_p2Speed;
uniform float u_mix;
uniform float u_blend;
// Warp
uniform float u_noiseScale;
uniform float u_warpAmt;
uniform float u_warpSpeed;
// Kaleidoscope & Transform
uniform float u_slices;
uniform float u_kAngle;
uniform float u_mirror;
uniform float u_zoom;
uniform float u_rotSpeed;
uniform float u_panX;
uniform float u_panY;
// Feedback & FX
uniform float u_fbAmt;
uniform float u_fbZoom;
uniform float u_fbRot;
uniform float u_fbBlend;
uniform float u_blur;
uniform float u_chroma;
uniform float u_pixel; // pixel size in px
uniform float u_vign;
uniform float u_strobe;
uniform float u_strobeDuty;
// Color
uniform float u_palette;
uniform float u_hueShift;
uniform float u_hueSpeed;
uniform float u_saturation;
uniform float u_contrast;
uniform float u_brightness;
uniform float u_gamma;
// Audio (preprocessed)
uniform float u_audioLevel; // 0..1
uniform float u_audioBass;
uniform float u_audioMid;
uniform float u_audioHigh;
// Helpers
float hash21(vec2 p){
p = fract(p*vec2(123.34, 456.21));
p += dot(p, p+45.32);
return fract(p.x * p.y);
}
vec2 hash22(vec2 p){
float n = sin(dot(p, vec2(41.0,289.0)));
return fract(vec2(262144.0,32768.0)*n);
}
float noise(vec2 p){
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f*f*(3.0-2.0*f);
float a = hash21(i+vec2(0,0));
float b = hash21(i+vec2(1,0));
float c = hash21(i+vec2(0,1));
float d = hash21(i+vec2(1,1));
return mix(mix(a,b,u.x), mix(c,d,u.x), u.y);
}
float fbm(vec2 p){
float a = 0.0;
float amp = 0.5;
float f = 1.0;
for(int i=0;i<5;i++){
a += amp * noise(p*f);
f *= 2.0;
amp *= 0.5;
}
return a;
}
mat2 rot(float a){ float c=cos(a), s=sin(a); return mat2(c,-s,s,c); }
// Patterns return 0..1
float patRings(vec2 p, float t){
float r = length(p);
return 0.5 + 0.5*sin(10.0*r + t);
}
float patStripes(vec2 p, float t){
return 0.5 + 0.5*sin(10.0*p.x + t);
}
float patChecker(vec2 p, float t){
float s = sin(6.0*p.x + t)*sin(6.0*p.y - t);
return 0.5 + 0.5*sign(s);
}
float patSwirl(vec2 p, float t){
float a = atan(p.y, p.x);
float r = length(p);
return 0.5 + 0.5*sin(8.0*a + 12.0*r + t*0.7);
}
float patFBM(vec2 p, float t){
return fbm(p*1.2 + vec2(0.0, t*0.1));
}
float getPattern(float typeId, vec2 p, float scale, float speed, float t){
p *= scale;
float tt = t * speed;
if(typeId < 0.5) return patRings(p, tt);
if(typeId < 1.5) return patStripes(p, tt);
if(typeId < 2.5) return patChecker(p, tt);
if(typeId < 3.5) return patSwirl(p, tt);
return patFBM(p, tt);
}
vec3 hsv2rgb(vec3 c){
vec3 p = abs(fract(c.xxx + vec3(0.0, 2.0/3.0, 1.0/3.0))*6.0-3.0);
return c.z * mix(vec3(1.0), clamp(p-1.0,0.0,1.0), c.y);
}
vec3 paletteCosine(float t, int pal){
// a + b*cos(2pi*(c*t + d))
vec3 a,b,c,d;
if(pal==0){ // Rainbow
a=vec3(0.5,0.5,0.5); b=vec3(0.5,0.5,0.5); c=vec3(1.0,1.0,1.0); d=vec3(0.0,0.33,0.67);
} else if(pal==1){ // Neon
a=vec3(0.2,0.1,0.3); b=vec3(0.8,0.3,0.9); c=vec3(1.0,1.0,1.0); d=vec3(0.0,0.2,0.4);
} else if(pal==2){ // Cyberpunk
a=vec3(0.08,0.05,0.12); b=vec3(0.9,0.2,0.6); c=vec3(1.0,1.0,1.0); d=vec3(0.0,0.5,0.25);
} else if(pal==3){ // Heat
a=vec3(0.1,0.0,0.0); b=vec3(1.0,0.5,0.0); c=vec3(1.0,1.0,1.0); d=vec3(0.0,0.15,0.25);
} else { // Cool
a=vec3(0.05,0.08,0.15); b=vec3(0.4,0.7,1.0); c=vec3(1.0,1.0,1.0); d=vec3(0.1,0.3,0.5);
}
return a + b*cos(6.28318*(c*t + d));
}
vec3 applyPalette(float v, float hueShift, float hueSpeed, float t, int pal){
// Mix between cosine palette and HSV cycle
float h = fract(hueShift + v + t * hueSpeed);
vec3 rainbow = hsv2rgb(vec3(h, 1.0, 1.0));
vec3 cosPal = paletteCosine(fract(v + t*0.05), pal);
return mix(cosPal, rainbow, 0.35);
}
vec3 blendOp(vec3 a, vec3 b, float mode){
if(mode<0.5) return mix(a,b,0.5);
if(mode<1.5) return a + b;
if(mode<2.5) return a * b;
if(mode<3.5) return 1.0 - (1.0-a)*(1.0-b); // screen
return abs(a-b);
}
void main(){
vec2 uv = v_uv;
vec2 res = u_res;
vec2 p = (uv*2.0-1.0);
p.x *= res.x/res.y;
// Pixelation
if(u_pixel > 1.0){
vec2 pix = vec2(u_pixel);
vec2 q = (floor(gl_FragCoord.xy / pix) * pix + 0.5*pix)/res;
p = (q*2.0-1.0);
p.x *= res.x/res.y;
uv = q;
}
float t = u_time;
// Transform: pan, rotate, zoom
p -= vec2(u_panX, u_panY);
float rotA = t*u_rotSpeed;
p = rot(rotA) * p;
p /= u_zoom;
// Kaleidoscope
float slices = max(1.0, floor(u_slices + 0.5));
float seg = 6.2831853 / slices;
float ang = atan(p.y,p.x) + u_kAngle;
float rad = length(p);
ang = mod(ang, seg);
if(u_mirror > 0.5){
ang = abs(ang - seg*0.5);
}
p = vec2(cos(ang), sin(ang)) * rad;
// Domain warp
vec2 wp = p * u_noiseScale + vec2(u_seed*0.123, u_seed*0.917);
float n1 = fbm(wp + vec2(0.0, t*u_warpSpeed*0.3));
float n2 = fbm(wp.yx + vec2(t*u_warpSpeed*0.25, 0.0));
vec2 wv = vec2(n1, n2) - 0.5;
p += wv * u_warpAmt;
// Patterns
float a = getPattern(u_p1Type, p, u_p1Scale, u_p1Speed, t);
float b = getPattern(u_p2Type, p, u_p2Scale, u_p2Speed, t);
float mixLin = mix(a,b,u_mix);
float mixBlend;
{
vec3 ca = vec3(a);
vec3 cb = vec3(b);
vec3 cm = blendOp(ca, cb, u_blend);
mixBlend = cm.r; // use r channel equivalently
}
float v = mix(mixLin, mixBlend, 0.5);
// Color
int pal = int(floor(u_palette+0.5));
vec3 col = applyPalette(v, u_hueShift, u_hueSpeed, t, pal);
// Basic tonemapping-like shaping by v
col *= 0.7 + 0.6*v;
// Feedback sample
vec2 uvf = uv;
// Feedback zoom
uvf -= 0.5;
float fbz = 1.0 + u_fbZoom;
uvf = rot(u_fbRot) * (uvf / fbz);
uvf += 0.5;
// Blur sample from previous
vec2 px = 1.0 / res;
vec3 prev = texture2D(u_prev, uvf).rgb;
if(u_blur > 0.001){
float r = u_blur*3.0;
vec3 acc = vec3(0.0);
acc += texture2D(u_prev, uvf + vec2(-r, -r)*px).rgb;
acc += texture2D(u_prev, uvf + vec2( 0., -r)*px).rgb;
acc += texture2D(u_prev, uvf + vec2( r, -r)*px).rgb;
acc += texture2D(u_prev, uvf + vec2(-r, 0.)*px).rgb;
acc += texture2D(u_prev, uvf).rgb;
acc += texture2D(u_prev, uvf + vec2( r, 0.)*px).rgb;
acc += texture2D(u_prev, uvf + vec2(-r, r)*px).rgb;
acc += texture2D(u_prev, uvf + vec2( 0., r)*px).rgb;
acc += texture2D(u_prev, uvf + vec2( r, r)*px).rgb;
prev = mix(prev, acc/9.0, clamp(u_blur,0.0,1.0));
}
// Chromatic aberration
if(u_chroma > 0.001){
vec2 off = px * (1.0 + 20.0*u_chroma);
float rr = texture2D(u_prev, uvf + off).r;
float gg = texture2D(u_prev, uvf - off).g;
float bb = texture2D(u_prev, uvf + off.yx).b;
prev = mix(prev, vec3(rr,gg,bb), u_chroma);
}
// Combine new color and feedback
vec3 comb = blendOp(col, prev, u_fbBlend);
vec3 outc = mix(col, comb, u_fbAmt);
// Post: strobe
if(u_strobe > 0.001){
float ph = fract(t * u_strobe);
float gate = step(ph, u_strobeDuty);
outc = mix(outc, vec3(1.0), gate*0.9);
}
// Vignette
if(u_vign > 0.001){
float d = length((uv-0.5)*vec2(res.x/res.y,1.0));
float vig = smoothstep(0.9, 0.2, d);
outc *= mix(1.0, vig, u_vign);
}
// Color correction
// Saturation
float luma = dot(outc, vec3(0.299,0.587,0.114));
outc = mix(vec3(luma), outc, u_saturation);
// Contrast
outc = (outc - 0.5)*u_contrast + 0.5;
// Brightness
outc *= u_brightness;
// Gamma
outc = pow(max(outc,0.0), vec3(max(u_gamma, 0.0001)));
gl_FragColor = vec4(outc, 1.0);
}`;
const blitSrc = `
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_tex;
void main(){
gl_FragColor = texture2D(u_tex, v_uv);
}`;
function compile(type, src){
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if(!gl.getShaderParameter(s, gl.COMPILE_STATUS)){
console.error(gl.getShaderInfoLog(s));
throw new Error('Shader compile error');
}
return s;
}
function program(vs, fs){
const p = gl.createProgram();
gl.attachShader(p, compile(gl.VERTEX_SHADER, vs));
gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fs));
gl.bindAttribLocation(p, 0, 'a_pos');
gl.linkProgram(p);
if(!gl.getProgramParameter(p, gl.LINK_STATUS)){
console.error(gl.getProgramInfoLog(p));
throw new Error('Program link error');
}
return p;
}
const prog = program(vertSrc, fragSrc);
const blit = program(vertSrc, blitSrc);
const quad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quad);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1,-1, 1,-1, -1,1,
1,-1, 1, 1, -1,1,
]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
function makeTex(w,h){
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);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
return tex;
}
function makeFBO(tex){
const fb = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
return fb;
}
let W=0, H=0, DPR=1;
let tex = [null,null], fbo=[null,null], src=0, dst=1;
function resize(){
const dpr = Math.min(2, window.devicePixelRatio || 1);
const w = Math.floor(gl.canvas.clientWidth * dpr);
const h = Math.floor(gl.canvas.clientHeight * dpr);
if(w===W && h===H && dpr===DPR) return;
DPR=dpr; W=w; H=h;
gl.canvas.width = W; gl.canvas.height = H;
for(let i=0;i<2;i++){
if(tex[i]) gl.deleteTexture(tex[i]);
if(fbo[i]) gl.deleteFramebuffer(fbo[i]);
tex[i] = makeTex(W,H);
fbo[i] = makeFBO(tex[i]);
}
// Clear initial
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo[0]);
gl.viewport(0,0,W,H);
gl.clearColor(0,0,0,1); gl.clear(gl.COLOR_BUFFER_BIT);
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo[1]);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
src=0; dst=1;
}
// Params
const params = {
bpm:120, timeScale:1.0, seed:133,
p1Type:0, p2Type:4, p1Scale:3.0, p2Scale:2.2, p1Speed:0.3, p2Speed:-0.2, mix:0.5, blend:3,
noiseScale:2.0, warpAmount:0.35, warpSpeed:0.6,
slices:6, kAngle:0.0, mirror:1, zoom:1.0, rotSpeed:0.25, panX:0, panY:0,
fbAmt:0.22, fbZoom:-0.02, fbRot:0.02, fbBlend:4, blur:0.0, chroma:0.2, pixel:0.0, vign:0.2, strobe:0.0, strobeDuty:0.5,
palette:0, hueShift:0.0, hueSpeed:0.05, saturation:1.0, contrast:1.15, brightness:1.05, gamma:1.0,
audioAmt:0.5, audioHue:0.3, audioZoom:0.1, audioWarp:0.2, audioSlices:0.2, audioSmooth:0.5,
};
const ranges = [
['bpm','BPM',40,200,1,''],
['timeScale','Zeit-Skala',0.1,3,0.01,'x'],
['seed','Seed',0,999,1,''],
['p1Scale','A Scale',0.2,8,0.01,'x'],
['p2Scale','B Scale',0.2,8,0.01,'x'],
['p1Speed','A Speed',-4,4,0.01,''],
['p2Speed','B Speed',-4,4,0.01,''],
['mix','Mix',0,1,0.001,''],
['noiseScale','Noise Scale',0.1,8,0.01,'x'],
['warpAmount','Warp',0,2,0.001,''],
['warpSpeed','Warp Speed',-4,4,0.01,''],
['slices','Slices',1,16,1,''],
['kAngle','Angle',-Math.PI,Math.PI,0.001,'rad'],
['mirror','Mirror',0,1,1,''],
['zoom','Zoom',0.2,3,0.001,'x'],
['rotSpeed','Rotate Speed',-4,4,0.01,''],
['panX','Pan X',-1,1,0.001,''],
['panY','Pan Y',-1,1,0.001,''],
['fbAmt','FB Amount',0,1,0.001,''],
['fbZoom','FB Zoom',-0.2,0.2,0.001,''],
['fbRot','FB Rotate',-2,2,0.001,''],
['blur','Blur',0,1,0.001,''],
['chroma','Chroma',0,1,0.001,''],
['pixel','Pixelation',0,1,0.001,''],
['vign','Vignette',0,1,0.001,''],
['strobe','Strobe',0,20,0.01,'Hz'],
['strobeDuty','Duty',0.02,0.98,0.01,''],
['hueShift','Hue Shift',0,1,0.001,''],
['hueSpeed','Hue Speed',-1,1,0.001,''],
['saturation','Saturation',0,2,0.001,''],
['contrast','Contrast',0.2,3,0.001,''],
['brightness','Brightness',0,3,0.001,''],
['gamma','Gamma',0.2,3,0.001,''],
['audioAmt','Audio Amount',0,2,0.001,''],
['audioHue','Audio→Hue',0,1,0.001,''],
['audioZoom','Audio→Zoom',0,1,0.001,''],
['audioWarp','Audio→Warp',0,1,0.001,''],
['audioSlices','Audio→Slices',0,1,0.001,''],
['audioSmooth','Audio Smooth',0,0.99,0.001,''],
];
// LFOs
const lfoTargets = [
['none','–'],
['hueShift','Hue'],
['zoom','Zoom'],
['rotSpeed','Rotate'],
['warpAmount','Warp'],
['slices','Slices'],
['fbAmt','FB Amount'],
['blur','Blur'],
['chroma','Chroma'],
['pixel','Pixelation'],
['brightness','Brightness'],
['contrast','Contrast'],
['saturation','Saturation'],
['mix','Mix'],
];
const lfos = [
{type:'sine', rate:0.30, depth:0.40, phase:0, tgt1:'hueShift', amt1:0.35, tgt2:'warpAmount', amt2:0.25},
{type:'sine', rate:0.12, depth:0.30, phase:0.2, tgt1:'zoom', amt1:0.15, tgt2:'rotSpeed', amt2:0.5},
{type:'triangle', rate:0.07, depth:0.5, phase:0.5, tgt1:'slices', amt1:2.5, tgt2:'saturation', amt2:0.3},
];
// UI hookup
function byId(id){ return document.getElementById(id); }
function setRange(id, value){
const el = byId(id);
if(!el) return;
el.value = value;
const valEl = byId(id+'Val');
if(valEl) valEl.textContent = Number(value).toFixed(el.step && Number(el.step)<1 ? (String(el.step).split('.')[1]?.length||0) : 0);
}
function linkRange(id, key){
const el = byId(id); const valEl = byId(id+'Val');
if(!el) return;
el.addEventListener('input', () => {
params[key] = parseFloat(el.value);
if(valEl) valEl.textContent = el.value;
saveLocal();
});
setRange(id, params[key]);
}
// Attach ranges
linkRange('bpm','bpm'); linkRange('timeScale','timeScale'); linkRange('seed','seed');
// Pattern selects
const p1TypeSel = byId('p1Type');
const p2TypeSel = byId('p2Type');
p1TypeSel.value = params.p1Type; p2TypeSel.value = params.p2Type;
p1TypeSel.onchange = () => { params.p1Type = parseInt(p1TypeSel.value); saveLocal(); };
p2TypeSel.onchange = () => { params.p2Type = parseInt(p2TypeSel.value); saveLocal(); };
// Pattern ranges
linkRange('p1Scale','p1Scale'); linkRange('p2Scale','p2Scale');
linkRange('p1Speed','p1Speed'); linkRange('p2Speed','p2Speed');
linkRange('mix','mix');
const blendSel = byId('blend'); blendSel.value = params.blend; blendSel.onchange = () => { params.blend = parseInt(blendSel.value); saveLocal(); };
linkRange('noiseScale','noiseScale'); linkRange('warpAmount','warpAmount'); linkRange('warpSpeed','warpSpeed');
// Kalei & Transform
linkRange('slices','slices'); linkRange('kAngle','kAngle'); linkRange('mirror','mirror');
linkRange('zoom','zoom'); linkRange('rotSpeed','rotSpeed'); linkRange('panX','panX'); linkRange('panY','panY');
// Feedback
linkRange('fbAmt','fbAmt'); linkRange('fbZoom','fbZoom'); linkRange('fbRot','fbRot');
const fbBlendSel = byId('fbBlend'); fbBlendSel.value = params.fbBlend; fbBlendSel.onchange = () => { params.fbBlend = parseInt(fbBlendSel.value); saveLocal(); };
linkRange('blur','blur'); linkRange('chroma','chroma'); linkRange('pixel','pixel'); linkRange('vign','vign'); linkRange('strobe','strobe'); linkRange('strobeDuty','strobeDuty');
// Color
const paletteSel = byId('palette'); paletteSel.value = params.palette; paletteSel.onchange = () => { params.palette = parseInt(paletteSel.value); saveLocal(); };
linkRange('hueShift','hueShift'); linkRange('hueSpeed','hueSpeed'); linkRange('saturation','saturation'); linkRange('contrast','contrast'); linkRange('brightness','brightness'); linkRange('gamma','gamma');
// Audio
linkRange('audioAmt','audioAmt'); linkRange('audioHue','audioHue'); linkRange('audioZoom','audioZoom'); linkRange('audioWarp','audioWarp'); linkRange('audioSlices','audioSlices'); linkRange('audioSmooth','audioSmooth');
// LFO UI
const lfoWrap = byId('lfos');
function lfoRow(i){
const l = lfos[i];
const wrap = document.createElement('div'); wrap.className='section'; wrap.style.margin='8px 0'; wrap.style.padding='8px';
wrap.innerHTML = `
<div class="grid">
<label>Type</label>
<div>
<select id="l${i}type">
<option value="sine">Sine</option>
<option value="triangle">Triangle</option>
<option value="saw">Saw</option>
<option value="square">Square</option>
</select>
</div>
<label>Rate (Hz)</label><div><input id="l${i}rate" type="range" min="0.01" max="4" step="0.01"><div class="val" id="l${i}rateVal"></div></div>
<label>Depth</label><div><input id="l${i}depth" type="range" min="-1" max="1" step="0.001"><div class="val" id="l${i}depthVal"></div></div>
<label>Phase</label><div><input id="l${i}phase" type="range" min="0" max="1" step="0.001"><div class="val" id="l${i}phaseVal"></div></div>
<label>Target A</label>
<div class="row">
<select id="l${i}t1"></select>
<input id="l${i}a1" type="range" min="-2" max="2" step="0.001" style="width:110px">
</div>
<label>Target B</label>
<div class="row">
<select id="l${i}t2"></select>
<input id="l${i}a2" type="range" min="-2" max="2" step="0.001" style="width:110px">
</div>
</div>
`;
lfoWrap.appendChild(wrap);
const tsel1 = wrap.querySelector(`#l${i}t1`);
const tsel2 = wrap.querySelector(`#l${i}t2`);
lfoTargets.forEach(([v,lab])=>{
const o1=document.createElement('option');o1.value=v;o1.textContent=lab; tsel1.appendChild(o1);
const o2=document.createElement('option');o2.value=v;o2.textContent=lab; tsel2.appendChild(o2.cloneNode(true));
});
wrap.querySelector(`#l${i}type`).value=l.type;
wrap.querySelector(`#l${i}rate`).value=l.rate; wrap.querySelector(`#l${i}rateVal`).textContent=l.rate;
wrap.querySelector(`#l${i}depth`).value=l.depth; wrap.querySelector(`#l${i}depthVal`).textContent=l.depth;
wrap.querySelector(`#l${i}phase`).value=l.phase; wrap.querySelector(`#l${i}phaseVal`).textContent=l.phase;
tsel1.value = l.tgt1; tsel2.value = l.tgt2;
wrap.querySelector(`#l${i}a1`).value = l.amt1;
wrap.querySelector(`#l${i}a2`).value = l.amt2;
wrap.querySelector(`#l${i}type`).onchange = e=>{ l.type = e.target.value; saveLocal(); };
wrap.querySelector(`#l${i}rate`).oninput = e=>{ l.rate = parseFloat(e.target.value); wrap.querySelector(`#l${i}rateVal`).textContent=e.target.value; saveLocal(); };
wrap.querySelector(`#l${i}depth`).oninput = e=>{ l.depth = parseFloat(e.target.value); wrap.querySelector(`#l${i}depthVal`).textContent=e.target.value; saveLocal(); };
wrap.querySelector(`#l${i}phase`).oninput = e=>{ l.phase = parseFloat(e.target.value); wrap.querySelector(`#l${i}phaseVal`).textContent=e.target.value; saveLocal(); };
tsel1.onchange = e=>{ l.tgt1=e.target.value; saveLocal(); };
tsel2.onchange = e=>{ l.tgt2=e.target.value; saveLocal(); };
wrap.querySelector(`#l${i}a1`).oninput = e=>{ l.amt1 = parseFloat(e.target.value); saveLocal(); };
wrap.querySelector(`#l${i}a2`).oninput = e=>{ l.amt2 = parseFloat(e.target.value); saveLocal(); };
}
for(let i=0;i<lfos.length;i++) lfoRow(i);
// Presets
const presets = {
"Kaleido Trip": {
bpm:120,timeScale:1.0,seed:133,
p1Type:0,p2Type:4,p1Scale:3.2,p2Scale:2.0,p1Speed:0.42,p2Speed:-0.18,mix:0.52,blend:3,
noiseScale:2.0,warpAmount:0.45,warpSpeed:0.7,
slices:8,kAngle:0.0,mirror:1,zoom:1.05,rotSpeed:0.35,panX:0,panY:0,
fbAmt:0.28,fbZoom:-0.015,fbRot:0.015,fbBlend:4,blur:0.06,chroma:0.22,pixel:0.0,vign:0.25,strobe:0.0,strobeDuty:0.5,
palette:0,hueShift:0.0,hueSpeed:0.07,saturation:1.2,contrast:1.1,brightness:1.05,gamma:1.0,
audioAmt:0.6,audioHue:0.35,audioZoom:0.12,audioWarp:0.22,audioSlices:0.25,audioSmooth:0.6,
lfos:[
{type:'sine',rate:0.32,depth:0.45,phase:0.0,tgt1:'hueShift',amt1:0.35,tgt2:'warpAmount',amt2:0.25},
{type:'sine',rate:0.11,depth:0.34,phase:0.4,tgt1:'zoom',amt1:0.18,tgt2:'rotSpeed',amt2:0.6},
{type:'triangle',rate:0.09,depth:0.55,phase:0.2,tgt1:'slices',amt1:2.6,tgt2:'saturation',amt2:0.35},
],
},
"Neon Rings": {
bpm:128,timeScale:1.0,seed:77,
p1Type:0,p2Type:1,p1Scale:4.0,p2Scale:3.0,p1Speed:0.9,p2Speed:0.2,mix:0.4,blend:1,
noiseScale:1.2,warpAmount:0.2,warpSpeed:0.3,
slices:6,kAngle:0.2,mirror:1,zoom:1.15,rotSpeed:0.2,panX:0,panY:0,
fbAmt:0.2,fbZoom:-0.01,fbRot:0.01,fbBlend:3,blur:0.03,chroma:0.35,pixel:0.0,vign:0.2,strobe:0,strobeDuty:0.5,
palette:1,hueShift:0.1,hueSpeed:0.03,saturation:1.6,contrast:1.2,brightness:1.1,gamma:1.0,
audioAmt:0.5,audioHue:0.2,audioZoom:0.08,audioWarp:0.1,audioSlices:0.1,audioSmooth:0.5,
lfos:[
{type:'sine',rate:0.5,depth:0.5,phase:0.0,tgt1:'hueShift',amt1:0.25,tgt2:'zoom',amt2:0.12},
{type:'triangle',rate:0.22,depth:0.4,phase:0.2,tgt1:'rotSpeed',amt1:0.6,tgt2:'warpAmount',amt2:0.2},
{type:'sine',rate:0.08,depth:0.3,phase:0.8,tgt1:'saturation',amt1:0.4,tgt2:'contrast',amt2:0.2},
],
},
"Liquid Warp": {
bpm:110,timeScale:1.0,seed:311,
p1Type:4,p2Type:4,p1Scale:2.0,p2Scale:1.5,p1Speed:0.3,p2Speed:-0.25,mix:0.5,blend:0,
noiseScale:2.8,warpAmount:0.85,warpSpeed:0.9,
slices:4,kAngle:0.0,mirror:0,zoom:1.0,rotSpeed:0.05,panX:0,panY:0,
fbAmt:0.35,fbZoom:-0.03,fbRot:0.02,fbBlend:3,blur:0.15,chroma:0.25,pixel:0.05,vign:0.3,strobe:0,strobeDuty:0.5,
palette:4,hueShift:0.0,hueSpeed:0.02,saturation:1.1,contrast:1.05,brightness:1.1,gamma:1.1,
audioAmt:0.8,audioHue:0.25,audioZoom:0.15,audioWarp:0.3,audioSlices:0.1,audioSmooth:0.7,
lfos:[
{type:'sine',rate:0.18,depth:0.5,phase:0.3,tgt1:'warpAmount',amt1:0.5,tgt2:'zoom',amt2:-0.12},
{type:'triangle',rate:0.09,depth:0.4,phase:0.5,tgt1:'slices',amt1:1.5,tgt2:'saturation',amt2:0.2},
{type:'sine',rate:0.06,depth:0.3,phase:0.1,tgt1:'hueShift',amt1:0.2,tgt2:'contrast',amt2:-0.1},
],
},
"Data Checker": {
bpm:140,timeScale:1.0,seed:500,
p1Type:2,p2Type:1,p1Scale:3.5,p2Scale:4.0,p1Speed:0.5,p2Speed:-0.5,mix:0.5,blend:4,
noiseScale:1.5,warpAmount:0.25,warpSpeed:0.4,
slices:12,kAngle:0.0,mirror:1,zoom:1.1,rotSpeed:0.4,panX:0,panY:0,
fbAmt:0.18,fbZoom:-0.005,fbRot:0.02,fbBlend:4,blur:0.0,chroma:0.4,pixel:0.0,vign:0.1,strobe:6,strobeDuty:0.25,
palette:0,hueShift:0.2,hueSpeed:0.1,saturation:1.3,contrast:1.4,brightness:1.0,gamma:1.0,
audioAmt:0.9,audioHue:0.35,audioZoom:0.2,audioWarp:0.2,audioSlices:0.4,audioSmooth:0.5,
lfos:[
{type:'square',rate:0.5,depth:1,phase:0,tgt1:'mix',amt1:0.6,tgt2:'contrast',amt2:0.25},
{type:'saw',rate:0.25,depth:0.6,phase:0.3,tgt1:'slices',amt1:-3.0,tgt2:'rotSpeed',amt2:0.8},
{type:'sine',rate:0.12,depth:0.3,phase:0.5,tgt1:'hueShift',amt1:0.4,tgt2:'brightness',amt2:-0.2},
],
},
"Cosmic Swirl": {
bpm:100,timeScale:1.0,seed:42,
p1Type:3,p2Type:4,p1Scale:2.5,p2Scale:2.0,p1Speed:0.2,p2Speed:0.1,mix:0.6,blend:3,
noiseScale:2.0,warpAmount:0.5,warpSpeed:0.5,
slices:7,kAngle:0.3,mirror:1,zoom:0.9,rotSpeed:0.18,panX:0,panY:0,
fbAmt:0.26,fbZoom:-0.02,fbRot:0.03,fbBlend:3,blur:0.05,chroma:0.2,pixel:0.0,vign:0.25,strobe:0,strobeDuty:0.5,
palette:2,hueShift:0.05,hueSpeed:0.04,saturation:1.4,contrast:1.15,brightness:1.05,gamma:1.0,
audioAmt:0.5,audioHue:0.25,audioZoom:0.1,audioWarp:0.2,audioSlices:0.15,audioSmooth:0.6,
lfos:[
{type:'sine',rate:0.2,depth:0.5,phase:0.1,tgt1:'hueShift',amt1:0.3,tgt2:'warpAmount',amt2:0.3},
{type:'triangle',rate:0.1,depth:0.4,phase:0.6,tgt1:'zoom',amt1:0.1,tgt2:'rotSpeed',amt2:0.45},
{type:'sine',rate:0.08,depth:0.4,phase:0.8,tgt1:'slices',amt1:2.0,tgt2:'saturation',amt2:0.25},
],
},
"Heat Bloom": {
bpm:90,timeScale:1.0,seed:888,
p1Type:4,p2Type:0,p1Scale:1.6,p2Scale:2.8,p1Speed:-0.15,p2Speed:0.6,mix:0.55,blend:1,
noiseScale:2.6,warpAmount:0.75,warpSpeed:0.6,
slices:5,kAngle:0.0,mirror:0,zoom:1.0,rotSpeed:0.08,panX:0,panY:0,
fbAmt:0.32,fbZoom:-0.025,fbRot:0.015,fbBlend:3,blur:0.08,chroma:0.18,pixel:0.03,vign:0.3,strobe:0,strobeDuty:0.5,
palette:3,hueShift:0.0,hueSpeed:0.03,saturation:1.2,contrast:1.2,brightness:1.1,gamma:1.0,
audioAmt:0.7,audioHue:0.2,audioZoom:0.15,audioWarp:0.3,audioSlices:0.2,audioSmooth:0.7,
lfos:[
{type:'saw',rate:0.15,depth:0.5,phase:0.0,tgt1:'warpAmount',amt1:0.4,tgt2:'brightness',amt2:0.2},
{type:'sine',rate:0.11,depth:0.35,phase:0.2,tgt1:'hueShift',amt1:0.25,tgt2:'contrast',amt2:0.15},
{type:'triangle',rate:0.07,depth:0.4,phase:0.6,tgt1:'slices',amt1:1.8,tgt2:'zoom',amt2:-0.08},
],
},
};
const presetSel = byId('presetSel');
Object.keys(presets).forEach((name,i)=>{
const opt = document.createElement('option');
opt.value=name; opt.textContent = `${i+1}. ${name}`;
presetSel.appendChild(opt);
});
presetSel.onchange = ()=>{ loadPreset(presets[presetSel.value]); };
function applyObj(obj, dst){
for(const k in obj){
if(k==='lfos' && Array.isArray(obj.lfos)){
for(let i=0;i<lfos.length;i++){
Object.assign(lfos[i], obj.lfos[i] || {});
}
} else {
dst[k] = obj[k];
}
}
}
function loadPreset(p){
applyObj(p, params);
// Update UI
p1TypeSel.value = params.p1Type; p2TypeSel.value = params.p2Type;
blendSel.value = params.blend; fbBlendSel.value = params.fbBlend; paletteSel.value = params.palette;
ranges.forEach(([keyLabel])=>{
const id = keyLabel; if(byId(id)) setRange(id, params[id]);
});
// Update LFO UI
const selects = lfoWrap.querySelectorAll('select, input[type=range]');
// Re-render lfo rows by resetting values
lfoWrap.innerHTML=''; for(let i=0;i<lfos.length;i++) lfoRow(i);
saveLocal();
}
// Set default preset 1
presetSel.selectedIndex = 0; loadPreset(presets[presetSel.value]);
// JSON
byId('btnToJSON').onclick = ()=>{
const obj = {...params, lfos: lfos.map(x=>({...x}))};
byId('json').value = JSON.stringify(obj, null, 2);
};
byId('btnFromJSON').onclick = ()=>{
try{
const obj = JSON.parse(byId('json').value);
loadPreset(obj);
}catch(e){ alert('Ungültiges JSON'); }
};
byId('btnSave').onclick = ()=>{
const obj = {...params, lfos: lfos.map(x=>({...x}))};
const blob = new Blob([JSON.stringify(obj, null, 2)], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = 'visual-synth-preset.json'; a.click();
URL.revokeObjectURL(a.href);
};
byId('btnLoad').onclick = ()=>{
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'application/json';
inp.onchange = ()=>{
const f = inp.files[0]; if(!f) return;
const r = new FileReader();
r.onload = ()=>{ try{ const obj = JSON.parse(r.result); loadPreset(obj);}catch(e){ alert('Fehler beim Laden'); } };
r.readAsText(f);
};
inp.click();
};
// Randomize
function rand(min,max){ return Math.random()*(max-min)+min; }
function randInt(min,max){ return Math.floor(rand(min,max+1)); }
function randomize(){
params.seed = randInt(0,999);
params.p1Type = randInt(0,4);
params.p2Type = randInt(0,4);
params.p1Scale = rand(0.5,5);
params.p2Scale = rand(0.5,5);
params.p1Speed = rand(-1.5, 1.5);
params.p2Speed = rand(-1.5, 1.5);
params.mix = Math.random();
params.blend = randInt(0,4);
params.noiseScale = rand(0.8,3.5);
params.warpAmount = rand(0.1,1.2);
params.warpSpeed = rand(-1,1);
params.slices = randInt(3,14);
params.kAngle = rand(-Math.PI, Math.PI);
params.mirror = randInt(0,1);
params.zoom = rand(0.7,1.5);
params.rotSpeed = rand(-0.6,0.6);
params.panX = rand(-0.2,0.2);
params.panY = rand(-0.2,0.2);
params.fbAmt = rand(0.05,0.4);
params.fbZoom = rand(-0.03,0.03);
params.fbRot = rand(-0.05,0.05);
params.fbBlend = randInt(0,4);
params.blur = Math.pow(Math.random(),2)*0.2;
params.chroma = Math.random()*0.4;
params.pixel = Math.random()<0.3 ? Math.random()*0.15 : 0.0;
params.vign = rand(0.05,0.35);
params.strobe = Math.random()<0.2 ? rand(2,10) : 0;
params.strobeDuty = rand(0.1,0.6);
params.palette = randInt(0,4);
params.hueShift = Math.random();
params.hueSpeed = rand(-0.1,0.1);
params.saturation = rand(0.8,1.8);
params.contrast = rand(0.9,1.6);
params.brightness = rand(0.9,1.2);
params.gamma = rand(0.8,1.4);
// LFOs
const types = ['sine','triangle','saw','square'];
lfos.forEach(l=>{
l.type = types[randInt(0,types.length-1)];
l.rate = rand(0.05, 0.8);
l.depth = rand(-0.8, 0.8);
l.phase = Math.random();
const tkeys = lfoTargets.map(t=>t[0]).filter(x=>x!=='none');
l.tgt1 = tkeys[randInt(0,tkeys.length-1)];
l.tgt2 = tkeys[randInt(0,tkeys.length-1)];
l.amt1 = rand(-0.6, 0.6) * (l.tgt1==='slices'?4:1);
l.amt2 = rand(-0.6, 0.6) * (l.tgt2==='slices'?4:1);
});
// Reflect UI
p1TypeSel.value=params.p1Type; p2TypeSel.value=params.p2Type; blendSel.value=params.blend; fbBlendSel.value=params.fbBlend; paletteSel.value=params.palette;
ranges.forEach(([id])=>{ if(byId(id)) setRange(id, params[id]); });
lfoWrap.innerHTML=''; for(let i=0;i<lfos.length;i++) lfoRow(i);
saveLocal();
}
byId('btnRandom').onclick = randomize;
// Local storage
const LSKEY = 'visual-synth-state-v1';
function saveLocal(){
try{
localStorage.setItem(LSKEY, JSON.stringify({params, lfos}));
}catch(e){}
}
function loadLocal(){
try{
const s = localStorage.getItem(LSKEY);
if(!s) return;
const obj = JSON.parse(s);
if(obj.params) applyObj(obj.params, params);
if(obj.lfos) for(let i=0;i<Math.min(lfos.length, obj.lfos.length);i++) Object.assign(lfos[i], obj.lfos[i]);
// reflect UI quickly:
p1TypeSel.value=params.p1Type; p2TypeSel.value=params.p2Type; blendSel.value=params.blend; fbBlendSel.value=params.fbBlend; paletteSel.value=params.palette;
ranges.forEach(([id])=>{ if(byId(id)) setRange(id, params[id]); });
lfoWrap.innerHTML=''; for(let i=0;i<lfos.length;i++) lfoRow(i);
}catch(e){}
}
loadLocal();
// Keyboard
window.addEventListener('keydown', (e)=>{
if(e.repeat) return;
if(e.key==='f' || e.key==='F'){ toggleFullscreen(); }
if(e.key==='r' || e.key==='R'){ randomize(); }
if(e.key==='m' || e.key==='M'){ toggleMic(); }
if('123456'.includes(e.key)){
const idx = Number(e.key)-1;
const name = Object.keys(presets)[idx];
if(name){ presetSel.value = name; loadPreset(presets[name]); }
}
});
// Fullscreen
function toggleFullscreen(){
if(!document.fullscreenElement) document.documentElement.requestFullscreen().catch(()=>{});
else document.exitFullscreen();
}
byId('btnFull').onclick = toggleFullscreen;
// Recording
let recorder=null, chunks=[];
byId('btnRecord').onclick = ()=>{
if(!recorder){
const stream = canvas.captureStream(60);
recorder = new MediaRecorder(stream, {mimeType:'video/webm;codecs=vp9'});
recorder.ondataavailable = e=>{ if(e.data.size>0) chunks.push(e.data); };
recorder.onstop = ()=>{
const blob = new Blob(chunks, {type:'video/webm'});
chunks = [];
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'visual-synth.webm'; a.click();
URL.revokeObjectURL(url);
recorder = null;
byId('btnRecord').textContent = 'Aufnahme';
};
recorder.start();
byId('btnRecord').textContent = 'Aufnahme läuft...';
} else {
recorder.stop();
}
};
// Audio input
let audioCtx=null, analyser=null, micSource=null;
let audioOn=false;
const audioInfo = byId('audioInfo');
function setupAudio(){
if(audioCtx) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioCtx.createAnalyser();
analyser.fftSize = 1024;
analyser.smoothingTimeConstant = 0.5;
}
async function toggleMic(){
try{
if(!audioOn){
setupAudio();
const stream = await navigator.mediaDevices.getUserMedia({audio:true});
micSource = audioCtx.createMediaStreamSource(stream);
micSource.connect(analyser);
audioOn = true; byId('btnMic').textContent = 'Mic: An';
}else{
if(micSource) micSource.disconnect();
audioOn=false; byId('btnMic').textContent = 'Mic: Aus';
}
}catch(e){
alert('Mikrofonzugriff fehlgeschlagen.');
}
}
byId('btnMic').onclick = toggleMic;
// FPS
const fpsEl = byId('fps'); let lastFpsT=performance.now(), frames=0;
function updateFPS(){
frames++;
const now=performance.now();
if(now-lastFpsT>500){
const fps = Math.round(frames*1000/(now-lastFpsT));
fpsEl.textContent = String(fps);
frames=0; lastFpsT=now;
}
}
// Uniform locations
const U = {};
function getUniforms(p){
const names = [
'u_res','u_time','u_bpm','u_seed','u_prev',
'u_p1Type','u_p2Type','u_p1Scale','u_p2Scale','u_p1Speed','u_p2Speed','u_mix','u_blend',
'u_noiseScale','u_warpAmt','u_warpSpeed',
'u_slices','u_kAngle','u_mirror','u_zoom','u_rotSpeed','u_panX','u_panY',
'u_fbAmt','u_fbZoom','u_fbRot','u_fbBlend','u_blur','u_chroma','u_pixel','u_vign','u_strobe','u_strobeDuty',
'u_palette','u_hueShift','u_hueSpeed','u_saturation','u_contrast','u_brightness','u_gamma',
'u_audioLevel','u_audioBass','u_audioMid','u_audioHigh'
];
gl.useProgram(p);
for(const n of names) U[n] = gl.getUniformLocation(p, n);
}
getUniforms(prog);
// Time
let startT = performance.now() / 1000;
let lastT = startT;
// Audio data buffers
let fft=null, wave=null; let audioLevel=0, audioBass=0, audioMid=0, audioHigh=0;
function sampleAudio(dt){
if(!analyser || !audioOn) { audioLevel*=0.95; audioBass*=0.95; audioMid*=0.95; audioHigh*=0.95; audioInfo.textContent='Audio: –'; return; }
if(!fft){ fft = new Uint8Array(analyser.frequencyBinCount); wave = new Uint8Array(analyser.fftSize); }
analyser.getByteFrequencyData(fft);
analyser.getByteTimeDomainData(wave);
const N = fft.length;
// Bands: 0-200Hz, 200-1500Hz, 1.5k-8k (roughly)
const sr = audioCtx.sampleRate || 48000;
const hzPerBin = sr/2/N;
let sum=0, bsum=0, msum=0, hsum=0, bc=0, mc=0, hc=0;
for(let i=0;i<N;i++){
const hz = i*hzPerBin;
const v = fft[i]/255;
sum += v;
if(hz<200){ bsum+=v; bc++; }
else if(hz<1500){ msum+=v; mc++; }
else if(hz<8000){ hsum+=v; hc++; }
}
const smooth = params.audioSmooth;
audioLevel = audioLevel*(smooth) + (1-smooth)*(sum/N);
audioBass = audioBass*(smooth) + (1-smooth)*(bc?bsum/bc:0);
audioMid = audioMid*(smooth) + (1-smooth)*(mc?msum/mc:0);
audioHigh = audioHigh*(smooth) + (1-smooth)*(hc?hsum/hc:0);
audioInfo.textContent = `Audio: L ${audioLevel.toFixed(2)} B ${audioBass.toFixed(2)} M ${audioMid.toFixed(2)} H ${audioHigh.toFixed(2)}`;
}
function lfoValue(lfo, t){
const ph = (t * lfo.rate + lfo.phase) % 1;
const x = ph*2-1; // -1..1
switch(lfo.type){
case 'sine': return Math.sin(ph*2*Math.PI);
case 'triangle': return 1-2*Math.abs(ph*2-1);
case 'saw': return (ph*2-1);
case 'square': return ph<0.5 ? 1 : -1;
default: return 0;
}
}
function applyMods(base, t){
const out = {...base};
// LFOs
for(const L of lfos){
const val = lfoValue(L, t) * L.depth;
if(L.tgt1 && L.tgt1!=='none') out[L.tgt1] = (out[L.tgt1] ?? 0) + val * L.amt1;
if(L.tgt2 && L.tgt2!=='none') out[L.tgt2] = (out[L.tgt2] ?? 0) + val * L.amt2;
}
// Audio
const A = params.audioAmt;
out.hueShift = (out.hueShift ?? base.hueShift) + A * params.audioHue * audioLevel * 0.5;
out.zoom = clamp((out.zoom ?? base.zoom) + A * params.audioZoom * (audioBass-0.4) * 0.6, 0.2, 3);
out.warpAmount = clamp((out.warpAmount ?? base.warpAmount) + A * params.audioWarp * (audioMid-0.4) * 1.2, 0, 2);
out.slices = clamp((out.slices ?? base.slices) + A * params.audioSlices * (audioHigh-0.5) * 6.0, 1, 16);
return out;
}
function clamp(x,min,max){ return x<min?min:x>max?max:x; }
// Render loop
function draw(){
resize();
const now = performance.now()/1000;
const dt = Math.max(0.001, now-lastT); lastT=now;
sampleAudio(dt);
const t = (now - startT) * params.timeScale;
// Prepare final param set including mods
const P = applyMods(params, t);
// Upload uniforms & render to FBO[dst]
gl.useProgram(prog);
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo[dst]);
gl.viewport(0,0,W,H);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, tex[src]);
gl.uniform1i(U['u_prev'], 0);
gl.uniform2f(U['u_res'], W, H);
gl.uniform1f(U['u_time'], t);
gl.uniform1f(U['u_bpm'], params.bpm);
gl.uniform1f(U['u_seed'], P.seed);
gl.uniform1f(U['u_p1Type'], P.p1Type);
gl.uniform1f(U['u_p2Type'], P.p2Type);
gl.uniform1f(U['u_p1Scale'], P.p1Scale);
gl.uniform1f(U['u_p2Scale'], P.p2Scale);
gl.uniform1f(U['u_p1Speed'], P.p1Speed);
gl.uniform1f(U['u_p2Speed'], P.p2Speed);
gl.uniform1f(U['u_mix'], P.mix);
gl.uniform1f(U['u_blend'], P.blend);
gl.uniform1f(U['u_noiseScale'], P.noiseScale);
gl.uniform1f(U['u_warpAmt'], P.warpAmount);
gl.uniform1f(U['u_warpSpeed'], P.warpSpeed);
gl.uniform1f(U['u_slices'], P.slices);
gl.uniform1f(U['u_kAngle'], P.kAngle);
gl.uniform1f(U['u_mirror'], P.mirror);
gl.uniform1f(U['u_zoom'], P.zoom);
gl.uniform1f(U['u_rotSpeed'], P.rotSpeed);
gl.uniform1f(U['u_panX'], P.panX);
gl.uniform1f(U['u_panY'], P.panY);
gl.uniform1f(U['u_fbAmt'], P.fbAmt);
gl.uniform1f(U['u_fbZoom'], P.fbZoom);
gl.uniform1f(U['u_fbRot'], P.fbRot);
gl.uniform1f(U['u_fbBlend'], P.fbBlend);
gl.uniform1f(U['u_blur'], P.blur);
gl.uniform1f(U['u_chroma'], P.chroma);
const pixPx = P.pixel<=0 ? 0.0 : (1.0 + P.pixel*60.0);
gl.uniform1f(U['u_pixel'], pixPx);
gl.uniform1f(U['u_vign'], P.vign);
gl.uniform1f(U['u_strobe'], P.strobe);
gl.uniform1f(U['u_strobeDuty'], P.strobeDuty);
gl.uniform1f(U['u_palette'], P.palette);
gl.uniform1f(U['u_hueShift'], P.hueShift%1);
gl.uniform1f(U['u_hueSpeed'], P.hueSpeed);
gl.uniform1f(U['u_saturation'], P.saturation);
gl.uniform1f(U['u_contrast'], P.contrast);
gl.uniform1f(U['u_brightness'], P.brightness);
gl.uniform1f(U['u_gamma'], P.gamma);
gl.uniform1f(U['u_audioLevel'], audioLevel);
gl.uniform1f(U['u_audioBass'], audioBass);
gl.uniform1f(U['u_audioMid'], audioMid);
gl.uniform1f(U['u_audioHigh'], audioHigh);
gl.drawArrays(gl.TRIANGLES, 0, 6);
// Blit to screen
gl.useProgram(blit);
const loc = gl.getUniformLocation(blit,'u_tex');
gl.uniform1i(loc, 0);
gl.bindTexture(gl.TEXTURE_2D, tex[dst]);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0,0,W,H);
gl.drawArrays(gl.TRIANGLES, 0, 6);
// swap
const tmp=src; src=dst; dst=tmp;
updateFPS();
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
// Buttons and init
window.addEventListener('resize', resize);
// Init UI values for ranges
ranges.forEach(([id, label, min,max,step,unit])=>{
setRange(id, params[id]);
});
})();
</script>
</body>
</html>