Spaces:
Running
Running
<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> | |