Files
Viz/public/app.js
T

1802 lines
70 KiB
JavaScript

/**
* VIZ 2.0 Audio Visualizer Studio — Core Application & Rendering Engine
* Next-Gen Multi-Shape, Neon Glow & Modular Layer Renderer
*/
(function () {
'use strict';
// --- Configuration State ---
const state = {
audioLoaded: false,
audioBuffer: null,
audioFile: null,
imageFile: null,
coverImage: null,
audioSource: null,
audioCtx: null,
analyser: null,
gainNode: null,
isPlaying: false,
startTime: 0,
pauseOffset: 0,
duration: 0,
loop: true,
aspectRatio: '16:9',
width: 1920,
height: 1080,
// Beat Detection & Dynamics
currentPulse: 1.0,
currentRotation: 0,
shakeX: 0,
shakeY: 0,
shakeZoom: 1.0,
beatEnergy: 0,
gridOffset: 0,
// Particles Pool
particles: [],
// Export Controller
isExporting: false,
exportAbortController: null,
// Active Settings
config: Object.assign({}, (typeof window !== 'undefined' && window.VIZ_PRESETS && window.VIZ_PRESETS.trap_nation) || (typeof VIZ_PRESETS !== 'undefined' && VIZ_PRESETS.trap_nation) || {})
};
// DOM Elements
const canvas = document.getElementById('vizCanvas');
const ctx = canvas.getContext('2d', { alpha: true });
const canvasWrapper = document.getElementById('canvasWrapper');
const audioDropzone = document.getElementById('audioDropzone');
const imageDropzone = document.getElementById('imageDropzone');
const audioFileInput = document.getElementById('audioFileInput');
const imageFileInput = document.getElementById('imageFileInput');
const audioFileBadge = document.getElementById('audioFileBadge');
const imageFileBadge = document.getElementById('imageFileBadge');
const coverUploadContainer = document.getElementById('coverUploadContainer');
const btnPlayPause = document.getElementById('btnPlayPause');
const iconPlay = document.getElementById('iconPlay');
const iconPause = document.getElementById('iconPause');
const btnRewind = document.getElementById('btnRewind');
const btnForward = document.getElementById('btnForward');
const btnLoop = document.getElementById('btnLoop');
const seekScrubber = document.getElementById('seekScrubber');
const currentTimeEl = document.getElementById('currentTime');
const durationTimeEl = document.getElementById('durationTime');
const volumeSlider = document.getElementById('volumeSlider');
const aspectBtns = document.querySelectorAll('.aspect-btn');
const toggleSafeZone = document.getElementById('toggleSafeZone');
const safeZoneOverlay = document.getElementById('safeZoneOverlay');
const toggleAlphaGrid = document.getElementById('toggleAlphaGrid');
const quickPresetSelect = document.getElementById('quickPresetSelect');
const tabBtns = document.querySelectorAll('.tab-btn');
const tabPanels = document.querySelectorAll('.tab-panel');
const btnStartExport = document.getElementById('btnStartExport');
const headerExportBtn = document.getElementById('headerExportBtn');
const btnCancelExport = document.getElementById('btnCancelExport');
const exportProgressCard = document.getElementById('exportProgressCard');
const exportStatusText = document.getElementById('exportStatusText');
const exportPercent = document.getElementById('exportPercent');
const exportProgressBarFill = document.getElementById('exportProgressBarFill');
const exportFpsLive = document.getElementById('exportFpsLive');
const exportEta = document.getElementById('exportEta');
const btnSavePreset = document.getElementById('btnSavePreset');
const btnExportPresetJson = document.getElementById('btnExportPresetJson');
const importPresetInput = document.getElementById('importPresetInput');
// Generate Default Synthwave Cover
function generateDefaultCover() {
const off = document.createElement('canvas');
off.width = 600;
off.height = 600;
const c = off.getContext('2d');
const grad = c.createLinearGradient(0, 0, 600, 600);
grad.addColorStop(0, '#0d1322');
grad.addColorStop(0.5, '#1e0d2b');
grad.addColorStop(1, '#080a12');
c.fillStyle = grad;
c.fillRect(0, 0, 600, 600);
const sunGrad = c.createRadialGradient(300, 240, 10, 300, 240, 190);
sunGrad.addColorStop(0, '#ff007f');
sunGrad.addColorStop(0.6, '#ffaa00');
sunGrad.addColorStop(1, 'transparent');
c.fillStyle = sunGrad;
c.beginPath();
c.arc(300, 240, 190, 0, Math.PI * 2);
c.fill();
c.strokeStyle = 'rgba(0, 240, 255, 0.35)';
c.lineWidth = 2;
for (let i = 0; i <= 600; i += 40) {
c.beginPath();
c.moveTo(i, 360);
c.lineTo(300 + (i - 300) * 3, 600);
c.stroke();
}
for (let y = 360; y <= 600; y += 30) {
c.beginPath();
c.moveTo(0, y);
c.lineTo(600, y);
c.stroke();
}
c.fillStyle = '#ffffff';
c.shadowColor = '#00f0ff';
c.shadowBlur = 24;
c.font = '900 110px Orbitron, sans-serif';
c.textAlign = 'center';
c.textBaseline = 'middle';
c.fillText('VIZ', 300, 250);
const img = new Image();
img.src = off.toDataURL('image/png');
return img;
}
state.coverImage = generateDefaultCover();
// --- Audio Engine Setup ---
function initAudioContext() {
if (!state.audioCtx) {
const AudioCtxClass = window.AudioContext || window.webkitAudioContext;
state.audioCtx = new AudioCtxClass();
state.analyser = state.audioCtx.createAnalyser();
state.analyser.fftSize = 2048;
state.analyser.smoothingTimeConstant = 0.82;
state.gainNode = state.audioCtx.createGain();
state.gainNode.gain.value = parseFloat(volumeSlider.value);
state.gainNode.connect(state.audioCtx.destination);
}
if (state.audioCtx.state === 'suspended') {
state.audioCtx.resume();
}
}
async function loadAudioFile(file) {
if (!file) return;
initAudioContext();
stopPlayback();
audioFileBadge.textContent = 'Lade Audio-Datei...';
audioFileBadge.classList.remove('hidden');
state.audioFile = file;
const arrayBuffer = await file.arrayBuffer();
state.audioCtx.decodeAudioData(
arrayBuffer,
(decoded) => {
state.audioBuffer = decoded;
state.duration = decoded.duration;
state.audioLoaded = true;
durationTimeEl.textContent = formatTime(state.duration);
seekScrubber.max = state.duration;
seekScrubber.value = 0;
currentTimeEl.textContent = '00:00';
document.getElementById('timelineProgressBar').style.width = '0%';
audioFileBadge.textContent = `🎵 ${file.name} (${formatTime(state.duration)})`;
// Extract metadata if empty
const titleInput = document.getElementById('trackTitle');
if (titleInput && (!titleInput.value || titleInput.value === 'Dein Songtitel')) {
const clean = file.name.replace(/\.[^/.]+$/, '').replace(/[_-]/g, ' ');
titleInput.value = clean;
}
startPlayback(0);
},
(err) => {
alert('Fehler beim Dekodieren der Audio-Datei: ' + err);
audioFileBadge.textContent = 'Fehler beim Laden';
}
);
}
function loadImageFile(file) {
if (!file) return;
state.imageFile = file;
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
state.coverImage = img;
imageFileBadge.textContent = `🖼️ ${file.name}`;
imageFileBadge.classList.remove('hidden');
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function startPlayback(offsetSeconds = 0) {
initAudioContext();
if (!state.audioLoaded || !state.audioBuffer) {
state.isPlaying = true;
iconPlay.classList.add('hidden');
iconPause.classList.remove('hidden');
return;
}
if (state.audioSource) {
try {
state.audioSource.onended = null;
state.audioSource.stop();
state.audioSource.disconnect();
} catch (e) {}
state.audioSource = null;
}
state.audioSource = state.audioCtx.createBufferSource();
state.audioSource.buffer = state.audioBuffer;
state.audioSource.loop = state.loop;
state.audioSource.connect(state.analyser);
state.analyser.connect(state.gainNode);
const safeOffset = Math.max(0, Math.min(offsetSeconds, state.duration || 0));
state.startTime = state.audioCtx.currentTime - safeOffset;
state.pauseOffset = safeOffset;
state.audioSource.start(0, safeOffset);
state.isPlaying = true;
iconPlay.classList.add('hidden');
iconPause.classList.remove('hidden');
state.audioSource.onended = () => {
if (!state.loop && state.isPlaying) {
pausePlayback();
state.pauseOffset = 0;
seekScrubber.value = 0;
currentTimeEl.textContent = '00:00';
document.getElementById('timelineProgressBar').style.width = '0%';
}
};
}
function pausePlayback() {
if (state.audioSource) {
state.pauseOffset = getCurrentPlaybackTime();
try {
state.audioSource.onended = null;
state.audioSource.stop();
state.audioSource.disconnect();
} catch (e) {}
state.audioSource = null;
}
state.isPlaying = false;
iconPlay.classList.remove('hidden');
iconPause.classList.add('hidden');
}
function stopPlayback() {
if (state.audioSource) {
try {
state.audioSource.onended = null;
state.audioSource.stop();
state.audioSource.disconnect();
} catch (e) {}
state.audioSource = null;
}
state.isPlaying = false;
state.pauseOffset = 0;
iconPlay.classList.remove('hidden');
iconPause.classList.add('hidden');
}
function getCurrentPlaybackTime() {
if (!state.audioLoaded || !state.duration) return 0;
if (state.isPlaying && state.audioCtx) {
let t = (state.audioCtx.currentTime - state.startTime);
if (state.loop) {
return (t % state.duration + state.duration) % state.duration;
}
return Math.min(Math.max(0, t), state.duration);
}
return state.pauseOffset || 0;
}
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
// --- Particles Initializer & Pool ---
function initParticles(count) {
state.particles = [];
for (let i = 0; i < count; i++) {
state.particles.push({
x: (Math.random() - 0.5) * state.width,
y: (Math.random() - 0.5) * state.height,
z: Math.random() * 1000 + 100,
radius: Math.random() * 2.8 + 1.0,
alpha: Math.random() * 0.7 + 0.3,
speedMultiplier: Math.random() * 0.8 + 0.6,
vx: (Math.random() - 0.5) * 1.5,
vy: (Math.random() - 0.5) * 1.5
});
}
}
// --- Dynamic Color Gradient Builder ---
function createVisualizerGradient(targetCtx, cfg, radius, angle, time) {
const mode = cfg.colorMode || 'gradient_2';
const c1 = cfg.colorGrad1 || '#00f0ff';
const c2 = cfg.colorGrad2 || '#ff007f';
const c3 = cfg.colorGrad3 || '#7000ff';
const style = cfg.gradientStyle || 'angular';
if (mode === 'rainbow') {
const grad = targetCtx.createConicGradient(time * 0.6, 0, 0);
grad.addColorStop(0, '#ff0055');
grad.addColorStop(0.2, '#ffaa00');
grad.addColorStop(0.4, '#00ff88');
grad.addColorStop(0.6, '#00f0ff');
grad.addColorStop(0.8, '#8a2be2');
grad.addColorStop(1, '#ff0055');
return grad;
}
if (mode === 'solid') {
return c1;
}
if (style === 'angular') {
const grad = targetCtx.createConicGradient(angle, 0, 0);
if (mode === 'gradient_3') {
grad.addColorStop(0, c1);
grad.addColorStop(0.33, c2);
grad.addColorStop(0.66, c3);
grad.addColorStop(1, c1);
} else {
grad.addColorStop(0, c1);
grad.addColorStop(0.5, c2);
grad.addColorStop(1, c1);
}
return grad;
} else if (style === 'radial') {
const grad = targetCtx.createRadialGradient(0, 0, radius * 0.4, 0, 0, radius * 2.2);
if (mode === 'gradient_3') {
grad.addColorStop(0, c1);
grad.addColorStop(0.5, c2);
grad.addColorStop(1, c3);
} else {
grad.addColorStop(0, c1);
grad.addColorStop(1, c2);
}
return grad;
} else if (style === 'linear_v') {
const grad = targetCtx.createLinearGradient(0, -radius * 1.5, 0, radius * 1.5);
if (mode === 'gradient_3') {
grad.addColorStop(0, c1);
grad.addColorStop(0.5, c2);
grad.addColorStop(1, c3);
} else {
grad.addColorStop(0, c1);
grad.addColorStop(1, c2);
}
return grad;
} else {
// Horizontal linear
const grad = targetCtx.createLinearGradient(-radius * 1.5, 0, radius * 1.5, 0);
if (mode === 'gradient_3') {
grad.addColorStop(0, c1);
grad.addColorStop(0.5, c2);
grad.addColorStop(1, c3);
} else {
grad.addColorStop(0, c1);
grad.addColorStop(1, c2);
}
return grad;
}
}
// --- Helper: Polygon Path Generator ---
function drawPolygonPath(targetCtx, sides, radius, cornerRadius = 0) {
targetCtx.beginPath();
if (sides === 0) {
// Circle
targetCtx.arc(0, 0, radius, 0, Math.PI * 2);
targetCtx.closePath();
return;
}
const angleStep = (Math.PI * 2) / sides;
const startAngle = -Math.PI / 2; // Point top
for (let i = 0; i < sides; i++) {
const a = startAngle + i * angleStep;
const x = Math.cos(a) * radius;
const y = Math.sin(a) * radius;
if (i === 0) targetCtx.moveTo(x, y);
else targetCtx.lineTo(x, y);
}
targetCtx.closePath();
}
// --- Helper: Hex to RGBA String ---
function hexToRgba(hex, alpha = 1.0) {
if (!hex || typeof hex !== 'string') return `rgba(255,255,255,${alpha})`;
let c = hex.replace('#', '');
if (c.length === 3) c = c.split('').map(x => x + x).join('');
const num = parseInt(c, 16);
if (isNaN(num)) return `rgba(255,255,255,${alpha})`;
const r = (num >> 16) & 255;
const g = (num >> 8) & 255;
const b = num & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
// --- Helper: Precise Polygon Perimeter Point & Outward Normal ---
function getPolygonPerimeterPoint(sides, radius, t) {
if (!sides || sides < 3) {
const angle = t * Math.PI * 2 - Math.PI / 2;
return {
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
nx: Math.cos(angle),
ny: Math.sin(angle),
angle: angle
};
}
const totalProgress = ((t % 1) + 1) % 1;
const sideProgress = totalProgress * sides;
const sideIdx = Math.floor(sideProgress) % sides;
const u = sideProgress - Math.floor(sideProgress);
const angleStep = (Math.PI * 2) / sides;
const a1 = -Math.PI / 2 + sideIdx * angleStep;
const a2 = -Math.PI / 2 + ((sideIdx + 1) % sides) * angleStep;
const x1 = Math.cos(a1) * radius;
const y1 = Math.sin(a1) * radius;
const x2 = Math.cos(a2) * radius;
const y2 = Math.sin(a2) * radius;
const px = x1 + u * (x2 - x1);
const py = y1 + u * (y2 - y1);
const dx = x2 - x1;
const dy = y2 - y1;
const len = Math.hypot(dx, dy) || 1;
// Clockwise polygon outward normal: (dy, -dx) / len
const nx = dy / len;
const ny = -dx / len;
return {
x: px,
y: py,
nx: nx,
ny: ny,
angle: Math.atan2(ny, nx)
};
}
function renderFrame(targetCtx, width, height, freqData, currentTime, duration, cfg, isPlaying = true) {
targetCtx.save();
targetCtx.clearRect(0, 0, width, height);
// 1. Analyze Frequency Bands
let subBassSum = 0;
let midBassSum = 0;
let trebleSum = 0;
const binCount = freqData.length;
for (let i = 0; i < 8; i++) subBassSum += freqData[i] || 0;
for (let i = 8; i < 28; i++) midBassSum += freqData[i] || 0;
for (let i = 40; i < 140; i++) trebleSum += freqData[i] || 0;
const subBassAvg = (subBassSum / 8) / 255;
const midBassAvg = (midBassSum / 20) / 255;
const trebleAvg = (trebleSum / 100) / 255;
const rawBassEnergy = isPlaying ? Math.max(subBassAvg * 1.25, midBassAvg) : 0;
state.beatEnergy = rawBassEnergy;
// Target Pulse calculation with smooth attack/decay
const targetPulse = isPlaying ? (1.0 + (rawBassEnergy * cfg.pulseIntensity * 0.6)) : 1.0;
const decay = cfg.pulseDecay || 0.8;
if (targetPulse > state.currentPulse) {
state.currentPulse = targetPulse;
} else {
state.currentPulse = state.currentPulse * decay + targetPulse * (1 - decay);
}
// Camera Shake & Bass Zoom calculation
let shakeZoom = 1.0;
let shakeX = 0;
let shakeY = 0;
if (isPlaying && cfg.enableShake && cfg.camShakeIntensity > 0 && rawBassEnergy > 0.35) {
const shakeFactor = (rawBassEnergy - 0.35) * cfg.camShakeIntensity * 38;
shakeX = (Math.random() - 0.5) * shakeFactor;
shakeY = (Math.random() - 0.5) * shakeFactor;
}
if (isPlaying && cfg.enableBassZoom && rawBassEnergy > 0.3) {
shakeZoom = 1.0 + (rawBassEnergy - 0.3) * 0.07;
}
// Rotation calculation
if (isPlaying) {
const rpm = cfg.rotationSpeed || 0;
state.currentRotation += (rpm * Math.PI * 2 / (60 * 60));
if (cfg.beatSpinImpulse && rawBassEnergy > 0.7) {
state.currentRotation += (rawBassEnergy - 0.7) * 0.045;
}
}
// --- LAYER 1: Background Mode ---
if (cfg.backgroundMode === 'blurred_cover' && state.coverImage && cfg.showCover) {
targetCtx.save();
targetCtx.filter = 'blur(45px) brightness(0.35)';
const scale = Math.max(width / state.coverImage.width, height / state.coverImage.height) * 1.25 * shakeZoom;
targetCtx.drawImage(
state.coverImage,
width / 2 - (state.coverImage.width * scale) / 2 + shakeX,
height / 2 - (state.coverImage.height * scale) / 2 + shakeY,
state.coverImage.width * scale,
state.coverImage.height * scale
);
targetCtx.restore();
} else if (cfg.backgroundMode === 'dark_vignette') {
const vig = targetCtx.createRadialGradient(width / 2, height / 2, width * 0.2, width / 2, height / 2, width * 0.75);
vig.addColorStop(0, '#101524');
vig.addColorStop(1, '#040609');
targetCtx.fillStyle = vig;
targetCtx.fillRect(0, 0, width, height);
} else if (cfg.backgroundMode === 'solid_black') {
targetCtx.fillStyle = '#000000';
targetCtx.fillRect(0, 0, width, height);
} // Transparent keeps pure alpha!
// --- LAYER 2: 3D Synthwave Grid Floor ---
if (cfg.enableGrid) {
targetCtx.save();
const horizonY = height * (cfg.gridPerspective || 0.65);
const gridColor = cfg.colorGrid || '#ff007f';
if (isPlaying) {
const speed = 120 * (1.0 + rawBassEnergy * 1.5);
state.gridOffset = (state.gridOffset + speed / 60) % 50;
}
targetCtx.strokeStyle = gridColor;
targetCtx.shadowColor = gridColor;
targetCtx.shadowBlur = cfg.enableGlow ? (15 * (cfg.glowIntensity || 0.8)) : 0;
targetCtx.lineWidth = 1.5;
targetCtx.globalAlpha = 0.85;
// Horizon glow line
targetCtx.beginPath();
targetCtx.moveTo(0, horizonY);
targetCtx.lineTo(width, horizonY);
targetCtx.stroke();
// Perspective vertical lines (radiating to vanishing point)
const numLines = 26;
for (let i = 0; i <= numLines; i++) {
const xBottom = (width / numLines) * i;
const xVanishing = width / 2 + (xBottom - width / 2) * 0.15;
targetCtx.beginPath();
targetCtx.moveTo(xVanishing, horizonY);
targetCtx.lineTo(xBottom, height);
targetCtx.stroke();
}
// Horizontal depth lines
for (let y = 0; y < 14; y++) {
const t = Math.pow((y + state.gridOffset / 50) / 14, 2.2);
const curY = horizonY + (height - horizonY) * t;
const wave = cfg.gridAudioReactive ? Math.sin(y * 0.8 + currentTime * 4) * (rawBassEnergy * 18) : 0;
targetCtx.beginPath();
targetCtx.moveTo(0, curY + wave);
targetCtx.lineTo(width, curY + wave);
targetCtx.stroke();
}
targetCtx.restore();
}
// --- LAYER 3: Beat Flash FX ---
if (cfg.beatFlash && rawBassEnergy > 0.85) {
targetCtx.fillStyle = `rgba(255, 255, 255, ${(rawBassEnergy - 0.85) * 0.45})`;
targetCtx.fillRect(0, 0, width, height);
}
// Transform Matrix for Centered World Coordinates & Camera Shake
targetCtx.save();
targetCtx.translate(width / 2 + shakeX, height / 2 + shakeY);
targetCtx.scale(shakeZoom, shakeZoom);
// --- LAYER 4: Particle System (3D Starfield / Dust / Sparks) ---
if (cfg.enableParticles && state.particles.length > 0) {
targetCtx.save();
const pType = cfg.particleType || 'starfield';
const speedMult = cfg.particleSpeed * (1.0 + (rawBassEnergy * cfg.particleWarp));
for (let i = 0; i < state.particles.length; i++) {
const p = state.particles[i];
if (isPlaying) {
if (pType === 'starfield') {
p.z -= speedMult * p.speedMultiplier * 6;
if (p.z <= 10) {
p.z = 1000;
p.x = (Math.random() - 0.5) * width;
p.y = (Math.random() - 0.5) * height;
}
} else if (pType === 'dust') {
p.x += p.vx * speedMult;
p.y += p.vy * speedMult;
if (p.x < -width / 2) p.x = width / 2;
if (p.x > width / 2) p.x = -width / 2;
if (p.y < -height / 2) p.y = height / 2;
if (p.y > height / 2) p.y = -height / 2;
} else if (pType === 'sparks') {
p.x += p.vx * speedMult * (1 + rawBassEnergy * 3);
p.y += p.vy * speedMult * (1 + rawBassEnergy * 3);
if (Math.hypot(p.x, p.y) > width * 0.6) {
p.x = (Math.random() - 0.5) * (cfg.circleRadius || 100);
p.y = (Math.random() - 0.5) * (cfg.circleRadius || 100);
}
}
}
if (pType === 'starfield') {
const k = 400 / p.z;
const px = p.x * k;
const py = p.y * k;
const pSize = Math.max(0.5, p.radius * k);
if (Math.abs(px) < width / 2 && Math.abs(py) < height / 2) {
targetCtx.fillStyle = cfg.colorParticle || '#ffffff';
targetCtx.globalAlpha = Math.min(1.0, p.alpha * (1 - p.z / 1000));
targetCtx.beginPath();
targetCtx.arc(px, py, pSize, 0, Math.PI * 2);
targetCtx.fill();
}
} else if (pType === 'dust') {
targetCtx.fillStyle = cfg.colorParticle || '#ffffff';
targetCtx.globalAlpha = p.alpha * 0.6;
targetCtx.beginPath();
targetCtx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
targetCtx.fill();
} else if (pType === 'sparks') {
targetCtx.fillStyle = cfg.colorParticle || '#ffffff';
targetCtx.globalAlpha = Math.min(1.0, p.alpha * (0.8 + rawBassEnergy * 0.5));
targetCtx.beginPath();
targetCtx.arc(p.x, p.y, p.radius * 1.2, 0, Math.PI * 2);
targetCtx.fill();
}
}
targetCtx.restore();
}
// Prepare Shape Geometry Values
const shape = cfg.shapeType || 'circle';
const baseRadius = (cfg.circleRadius || 150) * state.currentPulse;
const barCount = cfg.barCount || 96;
const maxHeight = cfg.spectrumMaxHeight || 140;
const barSpacing = cfg.barSpacing || 10;
const sensitivity = cfg.freqSensitivity || 1.3;
// Filter Frequency Array for Bars
const processedBars = [];
for (let i = 0; i < barCount; i++) {
let freqIndex;
if (cfg.mirrorSpectrum) {
const half = barCount / 2;
const symIdx = i < half ? i : barCount - i;
freqIndex = Math.floor((symIdx / half) * (binCount * 0.45));
} else {
freqIndex = Math.floor((i / barCount) * (binCount * 0.45));
}
if (cfg.freqFocus === 'bass_heavy') {
freqIndex = Math.floor(Math.pow(i / barCount, 1.8) * (binCount * 0.35));
}
const val = (freqData[freqIndex] || 0) / 255;
const heightVal = Math.pow(val, 1.2) * maxHeight * sensitivity;
processedBars.push(Math.max(2, heightVal));
}
// Build Visualizer Color Gradient
const spectrumGrad = createVisualizerGradient(
targetCtx,
cfg,
baseRadius,
state.currentRotation,
currentTime
);
// --- LAYER 5: Volumetric Neon Bloom & Background Aura ---
if (cfg.enableGlow && cfg.glowIntensity > 0) {
targetCtx.save();
const intensity = cfg.glowIntensity || 0.75;
if (shape === 'linear' || shape === 'oscilloscope') {
const auraH = maxHeight * 1.5;
const auraGrad = targetCtx.createLinearGradient(0, -auraH, 0, auraH);
const a1 = Math.min(0.35, 0.22 * intensity * (0.8 + rawBassEnergy * 0.5));
auraGrad.addColorStop(0, hexToRgba(cfg.colorGrad1 || '#00f0ff', 0));
auraGrad.addColorStop(0.5, hexToRgba(cfg.colorGrad1 || '#00f0ff', a1));
auraGrad.addColorStop(1, hexToRgba(cfg.colorGrad2 || '#ff007f', 0));
targetCtx.fillStyle = auraGrad;
targetCtx.fillRect(-width * 0.45, -auraH, width * 0.9, auraH * 2);
} else {
const auraR = baseRadius + maxHeight * 1.35;
const auraGrad = targetCtx.createRadialGradient(0, 0, baseRadius * 0.85, 0, 0, auraR);
const a1 = Math.min(0.35, 0.20 * intensity * (0.8 + rawBassEnergy * 0.5));
const a2 = Math.min(0.18, 0.08 * intensity);
auraGrad.addColorStop(0, hexToRgba(cfg.colorGrad1 || '#00f0ff', a1));
auraGrad.addColorStop(0.55, hexToRgba(cfg.colorGrad2 || '#ff007f', a2));
auraGrad.addColorStop(1, 'rgba(0, 0, 0, 0)');
targetCtx.fillStyle = auraGrad;
targetCtx.beginPath();
targetCtx.arc(0, 0, auraR, 0, Math.PI * 2);
targetCtx.fill();
}
targetCtx.restore();
}
// 3D Tunnel / Wormhole (If Shape === 'tunnel')
if (shape === 'tunnel') {
targetCtx.save();
const numRings = 10;
for (let r = numRings; r >= 1; r--) {
const depth = r + (currentTime * 1.5) % 1;
const ringRadius = baseRadius * (depth / numRings) * 2.2;
const ringAlpha = (1 - depth / (numRings + 1)) * 0.8;
const ringPulse = 1.0 + (rawBassEnergy * (1.2 - r / numRings) * 0.5);
targetCtx.strokeStyle = r % 2 === 0 ? cfg.colorGrad1 : cfg.colorGrad2;
targetCtx.lineWidth = Math.max(1, 4 * (1 - depth / numRings));
targetCtx.globalAlpha = ringAlpha;
targetCtx.beginPath();
targetCtx.arc(0, 0, ringRadius * ringPulse, 0, Math.PI * 2);
targetCtx.stroke();
}
targetCtx.restore();
}
// --- LAYER 6: Audio Spectrum Rendering (Radial, Polygon, Linear, Oscilloscope) ---
if (cfg.showSpectrum) {
targetCtx.save();
targetCtx.fillStyle = spectrumGrad;
targetCtx.strokeStyle = spectrumGrad;
// Subtle edge glow on individual elements (crisp, not bloated)
if (cfg.enableGlow && cfg.glowIntensity > 0) {
targetCtx.shadowColor = cfg.colorGrad1 || '#00f0ff';
targetCtx.shadowBlur = Math.min(6, (cfg.glowBlur || 20) * 0.15);
}
// 6.1 LINEAR & HORIZON MODE
if (shape === 'linear') {
const align = cfg.linearAlignment || 'center_mirrored';
const totalW = width * 0.85;
const barW = cfg.barWidth || 5;
const step = totalW / barCount;
const startX = -totalW / 2;
if (cfg.spectrumMode === 'smooth_wave') {
targetCtx.beginPath();
for (let i = 0; i < barCount; i++) {
const x = startX + i * step;
const h = processedBars[i];
const y = align === 'bottom_up' ? height * 0.35 - h : (align === 'top_down' ? -height * 0.35 + h : -h);
if (i === 0) targetCtx.moveTo(x, y);
else targetCtx.lineTo(x, y);
}
targetCtx.lineWidth = cfg.barWidth * 1.5;
targetCtx.lineCap = 'round';
targetCtx.lineJoin = 'round';
targetCtx.stroke();
if (align === 'center_mirrored') {
targetCtx.beginPath();
for (let i = 0; i < barCount; i++) {
const x = startX + i * step;
const h = processedBars[i];
if (i === 0) targetCtx.moveTo(x, h);
else targetCtx.lineTo(x, h);
}
targetCtx.stroke();
}
if (cfg.laserCore) {
targetCtx.save();
targetCtx.strokeStyle = '#ffffff';
targetCtx.lineWidth = Math.max(1.5, cfg.barWidth * 0.5);
targetCtx.stroke();
targetCtx.restore();
}
} else if (cfg.spectrumMode === 'block_matrix') {
const blockSize = 6;
const blockGap = 3;
for (let i = 0; i < barCount; i++) {
const x = startX + i * step;
const h = processedBars[i];
const blocks = Math.floor(h / (blockSize + blockGap));
for (let b = 0; b < blocks; b++) {
const by = align === 'bottom_up' ? height * 0.38 - (b * (blockSize + blockGap)) : - (b * (blockSize + blockGap));
targetCtx.fillRect(x - barW / 2, by, barW, blockSize);
if (align === 'center_mirrored') {
targetCtx.fillRect(x - barW / 2, b * (blockSize + blockGap), barW, blockSize);
}
}
}
} else {
// Standard Linear Bars (Crisp rounded lines)
targetCtx.lineWidth = barW;
targetCtx.lineCap = (cfg.barCornerRadius || 0) > 0 ? 'round' : 'butt';
for (let i = 0; i < barCount; i++) {
const x = startX + i * step;
const h = processedBars[i];
targetCtx.beginPath();
if (align === 'center_mirrored') {
targetCtx.moveTo(x, -h);
targetCtx.lineTo(x, h);
} else if (align === 'bottom_up') {
targetCtx.moveTo(x, height * 0.38);
targetCtx.lineTo(x, height * 0.38 - h);
} else if (align === 'top_down') {
targetCtx.moveTo(x, -height * 0.38);
targetCtx.lineTo(x, -height * 0.38 + h);
} else {
targetCtx.moveTo(x, -h / 2);
targetCtx.lineTo(x, h / 2);
}
targetCtx.stroke();
if (cfg.laserCore) {
targetCtx.save();
targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.85)';
targetCtx.lineWidth = Math.max(1, barW * 0.35);
targetCtx.stroke();
targetCtx.restore();
}
}
}
// 6.2 OSCILLOSCOPE / LASER WAVE MODE
} else if (shape === 'oscilloscope') {
const totalW = width * 0.85;
const step = totalW / barCount;
const startX = -totalW / 2;
targetCtx.beginPath();
for (let i = 0; i < barCount; i++) {
const x = startX + i * step;
const val = (freqData[i % binCount] || 0) / 255;
const oscY = Math.sin(i * 0.15 + currentTime * 8) * (val * maxHeight * 1.5) + (Math.cos(i * 0.3) * val * 20);
if (i === 0) targetCtx.moveTo(x, oscY);
else targetCtx.lineTo(x, oscY);
}
targetCtx.lineWidth = cfg.barWidth * 1.8;
targetCtx.lineCap = 'round';
targetCtx.lineJoin = 'round';
targetCtx.stroke();
if (cfg.laserCore) {
targetCtx.save();
targetCtx.strokeStyle = '#ffffff';
targetCtx.lineWidth = Math.max(1.5, cfg.barWidth * 0.6);
targetCtx.stroke();
targetCtx.restore();
}
// 6.3 POLYGON & RADIAL SPECTRUMS (Circle, Hexagon, Diamond, Triangle, Octagon, Tunnel)
} else {
const sidesMap = { circle: 0, hexagon: 6, diamond: 4, triangle: 3, octagon: 8, tunnel: 0 };
const sides = sidesMap[shape] || 0;
const barW = cfg.barWidth || 4;
if (cfg.spectrumMode === 'radial_bars') {
targetCtx.lineWidth = barW;
targetCtx.lineCap = (cfg.barCornerRadius || 0) > 0 ? 'round' : 'butt';
for (let i = 0; i < barCount; i++) {
const t = i / barCount;
const pt = getPolygonPerimeterPoint(sides, baseRadius, t);
const barH = processedBars[i];
const startDist = barSpacing;
const endDist = barSpacing + barH;
const x1 = pt.x + pt.nx * startDist;
const y1 = pt.y + pt.ny * startDist;
const x2 = pt.x + pt.nx * endDist;
const y2 = pt.y + pt.ny * endDist;
targetCtx.beginPath();
targetCtx.moveTo(x1, y1);
targetCtx.lineTo(x2, y2);
targetCtx.stroke();
if (cfg.laserCore) {
targetCtx.save();
targetCtx.strokeStyle = 'rgba(255, 255, 255, 0.85)';
targetCtx.lineWidth = Math.max(1, barW * 0.35);
targetCtx.beginPath();
targetCtx.moveTo(x1, y1);
targetCtx.lineTo(x2, y2);
targetCtx.stroke();
targetCtx.restore();
}
}
} else if (cfg.spectrumMode === 'smooth_wave') {
targetCtx.beginPath();
for (let i = 0; i <= barCount; i++) {
const idx = i % barCount;
const t = idx / barCount;
const pt = getPolygonPerimeterPoint(sides, baseRadius, t);
const rOffset = barSpacing + processedBars[idx];
const x = pt.x + pt.nx * rOffset;
const y = pt.y + pt.ny * rOffset;
if (i === 0) targetCtx.moveTo(x, y);
else targetCtx.lineTo(x, y);
}
targetCtx.closePath();
targetCtx.lineWidth = cfg.barWidth * 1.5;
targetCtx.lineCap = 'round';
targetCtx.lineJoin = 'round';
targetCtx.stroke();
if (cfg.laserCore) {
targetCtx.save();
targetCtx.strokeStyle = '#ffffff';
targetCtx.lineWidth = Math.max(1.5, cfg.barWidth * 0.5);
targetCtx.stroke();
targetCtx.restore();
}
} else if (cfg.spectrumMode === 'floating_dots') {
for (let i = 0; i < barCount; i++) {
const t = i / barCount;
const pt = getPolygonPerimeterPoint(sides, baseRadius, t);
const rOffset = barSpacing + processedBars[i];
const x = pt.x + pt.nx * rOffset;
const y = pt.y + pt.ny * rOffset;
targetCtx.beginPath();
targetCtx.arc(x, y, cfg.barWidth * 1.1, 0, Math.PI * 2);
targetCtx.fill();
}
} else if (cfg.spectrumMode === 'dual_ring') {
targetCtx.lineWidth = barW;
targetCtx.lineCap = (cfg.barCornerRadius || 0) > 0 ? 'round' : 'butt';
// Inner Ring
for (let i = 0; i < barCount; i++) {
const t = i / barCount;
const pt = getPolygonPerimeterPoint(sides, baseRadius, t);
const barH = processedBars[i] * 0.7;
const x1 = pt.x + pt.nx * barSpacing;
const y1 = pt.y + pt.ny * barSpacing;
const x2 = pt.x + pt.nx * (barSpacing + barH);
const y2 = pt.y + pt.ny * (barSpacing + barH);
targetCtx.beginPath();
targetCtx.moveTo(x1, y1);
targetCtx.lineTo(x2, y2);
targetCtx.stroke();
}
// Outer Ring (Orbital Dots)
targetCtx.fillStyle = cfg.colorGrad2 || '#ff007f';
for (let i = 0; i < barCount; i += 2) {
const t = (i / barCount + (currentTime * 0.05)) % 1;
const pt = getPolygonPerimeterPoint(sides, baseRadius, t);
const rOffset = barSpacing + maxHeight * 0.8 + (processedBars[i] * 0.4);
const x = pt.x + pt.nx * rOffset;
const y = pt.y + pt.ny * rOffset;
targetCtx.beginPath();
targetCtx.arc(x, y, barW * 0.9, 0, Math.PI * 2);
targetCtx.fill();
}
}
}
targetCtx.restore();
}
// --- LAYER 7: Central Core Artwork & Shape Mask ---
const sidesMap = { circle: 0, hexagon: 6, diamond: 4, triangle: 3, octagon: 8, linear: 0, oscilloscope: 0, tunnel: 0 };
const polySides = sidesMap[shape] || 0;
if (cfg.showCover && state.coverImage && shape !== 'linear' && shape !== 'oscilloscope') {
targetCtx.save();
targetCtx.imageSmoothingEnabled = true;
targetCtx.imageSmoothingQuality = 'high';
drawPolygonPath(targetCtx, polySides, baseRadius);
targetCtx.clip();
targetCtx.rotate(state.currentRotation);
targetCtx.drawImage(
state.coverImage,
-baseRadius,
-baseRadius,
baseRadius * 2,
baseRadius * 2
);
targetCtx.restore();
}
// --- LAYER 8: Shape Border & Progress Ring ---
if (cfg.showBorder && (cfg.borderWidth || 6) > 0 && shape !== 'linear' && shape !== 'oscilloscope') {
targetCtx.save();
drawPolygonPath(targetCtx, polySides, baseRadius);
targetCtx.lineWidth = cfg.borderWidth || 6;
targetCtx.strokeStyle = cfg.borderColor || '#ffffff';
targetCtx.lineJoin = 'round';
if (cfg.enableGlow) {
targetCtx.shadowColor = cfg.borderColor || '#ffffff';
targetCtx.shadowBlur = 8;
}
targetCtx.stroke();
targetCtx.restore();
}
if (cfg.showProgressRing && duration > 0 && shape !== 'linear' && shape !== 'oscilloscope') {
const progress = Math.min(1, Math.max(0, currentTime / duration));
const ringW = cfg.progressRingWidth || 6;
const ringRadius = baseRadius + (cfg.borderWidth || 6) / 2 + 2;
targetCtx.save();
targetCtx.lineWidth = ringW;
targetCtx.strokeStyle = cfg.progressRingColor || '#00f0ff';
targetCtx.lineCap = 'round';
targetCtx.lineJoin = 'round';
if (cfg.enableGlow) {
targetCtx.shadowColor = cfg.progressRingColor || '#00f0ff';
targetCtx.shadowBlur = 10;
}
targetCtx.beginPath();
if (polySides < 3) {
const progressAngle = progress * (Math.PI * 2);
targetCtx.arc(0, 0, ringRadius, -Math.PI / 2, -Math.PI / 2 + progressAngle);
} else {
const steps = polySides * 40;
const targetStep = Math.floor(progress * steps);
for (let s = 0; s <= targetStep; s++) {
const t = s / steps;
const pt = getPolygonPerimeterPoint(polySides, ringRadius, t);
if (s === 0) targetCtx.moveTo(pt.x, pt.y);
else targetCtx.lineTo(pt.x, pt.y);
}
if (progress > 0 && targetStep < steps) {
const ptEnd = getPolygonPerimeterPoint(polySides, ringRadius, progress);
targetCtx.lineTo(ptEnd.x, ptEnd.y);
}
}
targetCtx.stroke();
targetCtx.restore();
}
targetCtx.restore(); // Restore Centered Coordinates
// --- LAYER 9: Typography & Song Info (Crisp Studio Typography) ---
if (cfg.showTextOverlay) {
const title = document.getElementById('trackTitle').value || 'Song Title';
const artist = document.getElementById('trackArtist').value || 'Artist';
targetCtx.save();
targetCtx.textAlign = 'center';
targetCtx.textBaseline = 'middle';
let textY = height / 2 + baseRadius + maxHeight + 75;
if (cfg.textPosition === 'bottom_center') {
textY = height - 90;
} else if (cfg.textPosition === 'top_center') {
textY = 90;
}
const textPulse = cfg.textPulseWithBeat ? (1.0 + rawBassEnergy * 0.05) : 1.0;
targetCtx.translate(width / 2, textY);
targetCtx.scale(textPulse, textPulse);
// Clean drop shadow for maximum legibility on any background
targetCtx.shadowColor = 'rgba(0, 0, 0, 0.85)';
targetCtx.shadowBlur = 6;
targetCtx.shadowOffsetX = 0;
targetCtx.shadowOffsetY = 2;
// Title
targetCtx.font = `700 ${cfg.titleFontSize || 38}px ${cfg.fontFamily || "'Outfit', sans-serif"}`;
targetCtx.fillStyle = '#ffffff';
targetCtx.fillText(title, 0, 0);
// Artist
targetCtx.font = `500 ${cfg.artistFontSize || 22}px ${cfg.fontFamily || "'Outfit', sans-serif"}`;
targetCtx.fillStyle = 'rgba(255, 255, 255, 0.82)';
targetCtx.fillText(artist, 0, (cfg.titleFontSize || 38) * 0.8 + 8);
// Timecode
if (cfg.showTimecode && duration > 0) {
targetCtx.font = `600 16px 'Outfit', sans-serif`;
targetCtx.fillStyle = cfg.colorGrad1 || '#00f0ff';
targetCtx.fillText(
`${formatTime(currentTime)} / ${formatTime(duration)}`,
0,
(cfg.titleFontSize || 38) * 0.8 + (cfg.artistFontSize || 22) + 18
);
}
// Linear Progress Bar
if (cfg.showProgressBar && duration > 0) {
const barW = Math.min(width * 0.4, 400);
const barH = 4;
const barY = (cfg.titleFontSize || 38) * 0.8 + (cfg.artistFontSize || 22) + 36;
targetCtx.fillStyle = 'rgba(255, 255, 255, 0.2)';
targetCtx.fillRect(-barW / 2, barY, barW, barH);
const fillW = (currentTime / duration) * barW;
targetCtx.fillStyle = cfg.colorGrad1 || '#00f0ff';
targetCtx.fillRect(-barW / 2, barY, fillW, barH);
}
targetCtx.restore();
}
targetCtx.restore(); // Restore Root Context
}
// --- Real-Time Animation Loop (60 FPS) ---
const freqArray = new Uint8Array(1024);
function mainLoop() {
if (!state.isExporting) {
const curTime = getCurrentPlaybackTime();
if (state.isPlaying) {
if (state.audioLoaded && state.analyser) {
state.analyser.getByteFrequencyData(freqArray);
} else if (!state.audioLoaded) {
// Idle demo wave animation
const t = performance.now() * 0.003;
for (let i = 0; i < freqArray.length; i++) {
freqArray[i] = Math.max(0, Math.sin(t + i * 0.08) * 32);
}
}
} else {
// Paused: smoothly decay frequencies to 0 so visualizer stays quiet & frozen
for (let i = 0; i < freqArray.length; i++) {
freqArray[i] = Math.floor(freqArray[i] * 0.8);
}
}
if (state.isPlaying && state.audioLoaded) {
currentTimeEl.textContent = formatTime(curTime);
seekScrubber.value = curTime;
const pct = (curTime / (state.duration || 1)) * 100;
document.getElementById('timelineProgressBar').style.width = `${pct}%`;
}
renderFrame(ctx, state.width, state.height, freqArray, curTime, state.duration, state.config, state.isPlaying);
}
requestAnimationFrame(mainLoop);
}
// --- UI Synchronizer ---
function updateConfigFromUI() {
const checkedShape = document.querySelector('input[name="shapeType"]:checked');
if (checkedShape) state.config.shapeType = checkedShape.value;
state.config.showCover = document.getElementById('showCover').checked;
state.config.circleRadius = parseInt(document.getElementById('circleRadius').value, 10);
state.config.pulseIntensity = parseFloat(document.getElementById('pulseIntensity').value);
state.config.pulseDecay = parseFloat(document.getElementById('pulseDecay').value);
state.config.showBorder = document.getElementById('showBorder').checked;
state.config.borderWidth = parseInt(document.getElementById('borderWidth').value, 10);
state.config.borderColor = document.getElementById('borderColor').value;
state.config.showProgressRing = document.getElementById('showProgressRing').checked;
state.config.progressRingColor = document.getElementById('progressRingColor').value;
state.config.progressRingWidth = parseInt(document.getElementById('progressRingWidth').value, 10);
state.config.rotationSpeed = parseFloat(document.getElementById('rotationSpeed').value);
state.config.beatSpinImpulse = document.getElementById('beatSpinImpulse').checked;
state.config.showSpectrum = document.getElementById('showSpectrum').checked;
const checkedMode = document.querySelector('input[name="spectrumMode"]:checked');
if (checkedMode) state.config.spectrumMode = checkedMode.value;
state.config.linearAlignment = document.getElementById('linearAlignment').value;
state.config.barCount = parseInt(document.getElementById('barCount').value, 10);
state.config.spectrumMaxHeight = parseInt(document.getElementById('spectrumMaxHeight').value, 10);
state.config.barWidth = parseFloat(document.getElementById('barWidth').value);
state.config.barSpacing = parseInt(document.getElementById('barSpacing').value, 10);
state.config.barCornerRadius = parseFloat(document.getElementById('barCornerRadius').value);
state.config.freqSensitivity = parseFloat(document.getElementById('freqSensitivity').value);
state.config.freqFocus = document.getElementById('freqFocus').value;
state.config.mirrorSpectrum = document.getElementById('mirrorSpectrum').checked;
state.config.enableParticles = document.getElementById('enableParticles').checked;
state.config.particleType = document.getElementById('particleType').value;
state.config.particleCount = parseInt(document.getElementById('particleCount').value, 10);
state.config.particleSpeed = parseFloat(document.getElementById('particleSpeed').value);
state.config.particleWarp = parseFloat(document.getElementById('particleWarp').value);
state.config.enableGrid = document.getElementById('enableGrid').checked;
state.config.colorGrid = document.getElementById('colorGrid').value;
state.config.gridPerspective = parseFloat(document.getElementById('gridPerspective').value);
state.config.gridAudioReactive = parseFloat(document.getElementById('gridAudioReactive').value);
state.config.enableShake = document.getElementById('enableShake').checked;
state.config.camShakeIntensity = parseFloat(document.getElementById('camShakeIntensity').value);
state.config.enableGlitch = document.getElementById('enableGlitch').checked;
state.config.rgbSplitIntensity = parseFloat(document.getElementById('rgbSplitIntensity').value);
state.config.beatFlash = document.getElementById('beatFlash').checked;
state.config.enableBassZoom = document.getElementById('enableBassZoom').checked;
state.config.colorMode = document.getElementById('colorMode').value;
state.config.colorGrad1 = document.getElementById('colorGrad1').value;
state.config.colorGrad2 = document.getElementById('colorGrad2').value;
state.config.colorGrad3 = document.getElementById('colorGrad3').value;
state.config.colorParticle = document.getElementById('colorParticle').value;
state.config.gradientStyle = document.getElementById('gradientStyle').value;
state.config.enableGlow = document.getElementById('enableGlow').checked;
state.config.glowIntensity = parseFloat(document.getElementById('glowIntensity').value);
state.config.glowBlur = parseInt(document.getElementById('glowBlur').value, 10);
state.config.laserCore = document.getElementById('laserCore').checked;
state.config.backgroundMode = document.getElementById('backgroundMode').value;
state.config.showTextOverlay = document.getElementById('showTextOverlay').checked;
state.config.fontFamily = document.getElementById('fontFamily').value;
state.config.textPosition = document.getElementById('textPosition').value;
state.config.titleFontSize = parseInt(document.getElementById('titleFontSize').value, 10);
state.config.artistFontSize = parseInt(document.getElementById('artistFontSize').value, 10);
state.config.textPulseWithBeat = document.getElementById('textPulseWithBeat').checked;
state.config.showTimecode = document.getElementById('showTimecode').checked;
state.config.showProgressBar = document.getElementById('showProgressBar').checked;
// Toggle container visibility based on shape & toggles
document.getElementById('linearAlignmentSection').style.display = state.config.shapeType === 'linear' ? 'block' : 'none';
coverUploadContainer.style.opacity = state.config.showCover ? '1' : '0.4';
// Update value badge labels
document.getElementById('valCircleRadius').textContent = `${state.config.circleRadius}px`;
document.getElementById('valPulseIntensity').textContent = `${Math.round(state.config.pulseIntensity * 100)}%`;
document.getElementById('valPulseDecay').textContent = `${Math.round(state.config.pulseDecay * 100)}%`;
document.getElementById('valBorderWidth').textContent = `${state.config.borderWidth}px`;
document.getElementById('valProgressRingWidth').textContent = `${state.config.progressRingWidth}px`;
document.getElementById('valRotationSpeed').textContent = `${state.config.rotationSpeed} RPM`;
document.getElementById('valBarCount').textContent = state.config.barCount;
document.getElementById('valSpectrumMaxHeight').textContent = `${state.config.spectrumMaxHeight}px`;
document.getElementById('valBarWidth').textContent = `${state.config.barWidth}px`;
document.getElementById('valBarSpacing').textContent = `${state.config.barSpacing}px`;
document.getElementById('valBarCornerRadius').textContent = `${state.config.barCornerRadius}px`;
document.getElementById('valFreqSensitivity').textContent = `${state.config.freqSensitivity}x`;
document.getElementById('valParticleCount').textContent = state.config.particleCount;
document.getElementById('valParticleSpeed').textContent = `${state.config.particleSpeed}x`;
document.getElementById('valParticleWarp').textContent = `${state.config.particleWarp}x`;
document.getElementById('valGridPerspective').textContent = `${Math.round(state.config.gridPerspective * 100)}%`;
document.getElementById('valGridAudioReactive').textContent = `${Math.round(state.config.gridAudioReactive * 100)}%`;
document.getElementById('valCamShakeIntensity').textContent = `${Math.round(state.config.camShakeIntensity * 100)}%`;
document.getElementById('valRgbSplitIntensity').textContent = `${Math.round(state.config.rgbSplitIntensity * 100)}%`;
document.getElementById('valGlowIntensity').textContent = `${Math.round(state.config.glowIntensity * 100)}%`;
document.getElementById('valGlowBlur').textContent = `${state.config.glowBlur}px`;
document.getElementById('valTitleFontSize').textContent = `${state.config.titleFontSize}px`;
document.getElementById('valArtistFontSize').textContent = `${state.config.artistFontSize}px`;
initParticles(state.config.particleCount);
}
function applyPreset(presetKeyOrObj) {
const presetsObj = (typeof window !== 'undefined' && window.VIZ_PRESETS) || (typeof VIZ_PRESETS !== 'undefined' ? VIZ_PRESETS : {});
let p = typeof presetKeyOrObj === 'string' ? presetsObj[presetKeyOrObj] : presetKeyOrObj;
if (!p) return;
if (typeof presetKeyOrObj === 'string' && quickPresetSelect) {
quickPresetSelect.value = presetKeyOrObj;
}
Object.assign(state.config, p);
// Sync form inputs with preset
const shapeRadio = document.querySelector(`input[name="shapeType"][value="${state.config.shapeType || 'circle'}"]`);
if (shapeRadio) shapeRadio.checked = true;
document.getElementById('showCover').checked = state.config.showCover !== false;
document.getElementById('circleRadius').value = state.config.circleRadius || 150;
document.getElementById('pulseIntensity').value = state.config.pulseIntensity ?? 0.45;
document.getElementById('pulseDecay').value = state.config.pulseDecay ?? 0.8;
document.getElementById('showBorder').checked = state.config.showBorder !== false;
document.getElementById('borderWidth').value = state.config.borderWidth || 6;
document.getElementById('borderColor').value = state.config.borderColor || '#ffffff';
document.getElementById('showProgressRing').checked = state.config.showProgressRing !== false;
document.getElementById('progressRingColor').value = state.config.progressRingColor || '#00f0ff';
document.getElementById('progressRingWidth').value = state.config.progressRingWidth || 6;
document.getElementById('rotationSpeed').value = state.config.rotationSpeed ?? 3;
document.getElementById('beatSpinImpulse').checked = state.config.beatSpinImpulse !== false;
document.getElementById('showSpectrum').checked = state.config.showSpectrum !== false;
const modeRadio = document.querySelector(`input[name="spectrumMode"][value="${state.config.spectrumMode || 'radial_bars'}"]`);
if (modeRadio) modeRadio.checked = true;
document.getElementById('linearAlignment').value = state.config.linearAlignment || 'center_mirrored';
document.getElementById('barCount').value = state.config.barCount || 96;
document.getElementById('spectrumMaxHeight').value = state.config.spectrumMaxHeight || 130;
document.getElementById('barWidth').value = state.config.barWidth || 4;
document.getElementById('barSpacing').value = state.config.barSpacing || 12;
document.getElementById('barCornerRadius').value = state.config.barCornerRadius || 2;
document.getElementById('freqSensitivity').value = state.config.freqSensitivity || 1.3;
document.getElementById('freqFocus').value = state.config.freqFocus || 'balanced';
document.getElementById('mirrorSpectrum').checked = state.config.mirrorSpectrum !== false;
document.getElementById('enableParticles').checked = state.config.enableParticles !== false;
document.getElementById('particleType').value = state.config.particleType || 'starfield';
document.getElementById('particleCount').value = state.config.particleCount || 100;
document.getElementById('particleSpeed').value = state.config.particleSpeed || 1.0;
document.getElementById('particleWarp').value = state.config.particleWarp || 2.5;
document.getElementById('enableGrid').checked = !!state.config.enableGrid;
document.getElementById('colorGrid').value = state.config.colorGrid || '#ff007f';
document.getElementById('gridPerspective').value = state.config.gridPerspective || 0.65;
document.getElementById('gridAudioReactive').value = state.config.gridAudioReactive || 0.5;
document.getElementById('enableShake').checked = state.config.enableShake !== false;
document.getElementById('camShakeIntensity').value = state.config.camShakeIntensity || 0.35;
document.getElementById('enableGlitch').checked = state.config.enableGlitch !== false;
document.getElementById('rgbSplitIntensity').value = state.config.rgbSplitIntensity || 0.25;
document.getElementById('beatFlash').checked = !!state.config.beatFlash;
document.getElementById('enableBassZoom').checked = state.config.enableBassZoom !== false;
document.getElementById('colorMode').value = state.config.colorMode || 'gradient_2';
document.getElementById('colorGrad1').value = state.config.colorGrad1 || '#00f0ff';
document.getElementById('colorGrad2').value = state.config.colorGrad2 || '#ff007f';
document.getElementById('colorGrad3').value = state.config.colorGrad3 || '#7000ff';
document.getElementById('colorParticle').value = state.config.colorParticle || '#ffffff';
document.getElementById('gradientStyle').value = state.config.gradientStyle || 'angular';
document.getElementById('enableGlow').checked = state.config.enableGlow !== false;
document.getElementById('glowIntensity').value = state.config.glowIntensity || 0.75;
document.getElementById('glowBlur').value = state.config.glowBlur || 25;
document.getElementById('laserCore').checked = !!state.config.laserCore;
document.getElementById('backgroundMode').value = state.config.backgroundMode || 'transparent';
document.getElementById('showTextOverlay').checked = state.config.showTextOverlay !== false;
document.getElementById('fontFamily').value = state.config.fontFamily || "'Outfit', sans-serif";
document.getElementById('textPosition').value = state.config.textPosition || 'below_circle';
document.getElementById('titleFontSize').value = state.config.titleFontSize || 38;
document.getElementById('artistFontSize').value = state.config.artistFontSize || 22;
document.getElementById('textPulseWithBeat').checked = state.config.textPulseWithBeat !== false;
document.getElementById('showTimecode').checked = state.config.showTimecode !== false;
document.getElementById('showProgressBar').checked = !!state.config.showProgressBar;
updateConfigFromUI();
}
// --- Aspect Ratio Switching ---
function setAspectRatio(aspect) {
state.aspectRatio = aspect;
aspectBtns.forEach(btn => {
btn.classList.toggle('active', btn.dataset.aspect === aspect);
});
canvasWrapper.className = 'canvas-wrapper';
if (toggleAlphaGrid.checked) canvasWrapper.classList.add('alpha-bg');
if (aspect === '16:9') {
canvasWrapper.classList.add('aspect-16-9');
state.width = 1920;
state.height = 1080;
} else if (aspect === '9:16') {
canvasWrapper.classList.add('aspect-9-16');
state.width = 1080;
state.height = 1920;
} else if (aspect === '1:1') {
canvasWrapper.classList.add('aspect-1-1');
state.width = 1080;
state.height = 1080;
}
canvas.width = state.width;
canvas.height = state.height;
initParticles(state.config.particleCount);
}
// --- Video Export Engine (Client-Side High Quality Recorder) ---
let exportMediaRecorder = null;
let exportSourceNode = null;
let exportStreamDest = null;
let exportAnimFrame = null;
let exportStartTime = 0;
async function startExport() {
if (!state.audioLoaded || !state.audioBuffer) {
alert('Bitte lade zuerst eine Audio-Datei (.wav oder .mp3) im Tab "Media" hoch!');
document.querySelector('[data-tab="tab-media"]').click();
return;
}
const format = document.getElementById('exportFormat').value;
const res = document.getElementById('exportResolution').value;
const fps = parseInt(document.getElementById('exportFps').value, 10) || 60;
const origWidth = state.width;
const origHeight = state.height;
let targetWidth = 1920;
let targetHeight = 1080;
if (state.aspectRatio === '9:16') {
targetWidth = 1080;
targetHeight = 1920;
} else if (state.aspectRatio === '1:1') {
targetWidth = 1080;
targetHeight = 1080;
}
if (res === '4k') {
targetWidth *= 2;
targetHeight *= 2;
} else if (res === '720p') {
targetWidth = Math.round(targetWidth * 0.667);
targetHeight = Math.round(targetHeight * 0.667);
}
state.width = targetWidth;
state.height = targetHeight;
canvas.width = targetWidth;
canvas.height = targetHeight;
initParticles(state.config.particleCount);
pausePlayback();
state.isExporting = true;
exportProgressCard.classList.remove('hidden');
btnStartExport.classList.add('hidden');
exportStatusText.textContent = 'Bereite Video-Render vor...';
exportPercent.textContent = '0%';
exportProgressBarFill.style.width = '0%';
exportEta.textContent = `Gesamtlänge: ${formatTime(state.duration)}`;
try {
if (state.audioCtx.state === 'suspended') {
await state.audioCtx.resume();
}
exportStreamDest = state.audioCtx.createMediaStreamDestination();
exportSourceNode = state.audioCtx.createBufferSource();
exportSourceNode.buffer = state.audioBuffer;
// Connect source to analyser & stream destination
exportSourceNode.connect(state.analyser);
state.analyser.connect(exportStreamDest);
const canvasStream = canvas.captureStream(fps);
const audioTracks = exportStreamDest.stream.getAudioTracks();
const combinedStream = new MediaStream([...canvasStream.getVideoTracks(), ...audioTracks]);
let mimeType = 'video/webm;codecs=vp9,opus';
if (format === 'mp4' && MediaRecorder.isTypeSupported('video/mp4;codecs=avc1,mp4a.40.2')) {
mimeType = 'video/mp4;codecs=avc1,mp4a.40.2';
} else if (format === 'mp4' && MediaRecorder.isTypeSupported('video/mp4')) {
mimeType = 'video/mp4';
} else if (!MediaRecorder.isTypeSupported(mimeType)) {
if (MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus')) {
mimeType = 'video/webm;codecs=vp8,opus';
} else if (MediaRecorder.isTypeSupported('video/webm')) {
mimeType = 'video/webm';
}
}
const recordedChunks = [];
exportMediaRecorder = new MediaRecorder(combinedStream, {
mimeType: mimeType,
videoBitsPerSecond: 28000000 // 28 Mbps high quality
});
exportMediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
recordedChunks.push(event.data);
}
};
const totalDuration = state.duration;
exportMediaRecorder.onstop = () => {
state.isExporting = false;
exportProgressCard.classList.add('hidden');
btnStartExport.classList.remove('hidden');
// Restore canvas preview size
state.width = origWidth;
state.height = origHeight;
canvas.width = origWidth;
canvas.height = origHeight;
initParticles(state.config.particleCount);
if (recordedChunks.length > 0) {
const blob = new Blob(recordedChunks, { type: mimeType });
const ext = mimeType.includes('mp4') ? 'mp4' : 'webm';
const title = (document.getElementById('trackTitle').value || 'viz_video').replace(/[^a-zA-Z0-9_-]/g, '_');
const filename = `${title}_${format === 'webm_alpha' ? 'Alpha_DaVinci' : 'Visualizer'}.${ext}`;
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = downloadUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
}, 1000);
alert(`🎉 Fertig! Dein Video "${filename}" wurde erfolgreich heruntergeladen.`);
}
};
exportStartTime = state.audioCtx.currentTime;
// Render loop for export
const renderExportLoop = () => {
if (!state.isExporting) return;
const elapsed = state.audioCtx.currentTime - exportStartTime;
const currentPlaybackTime = Math.min(elapsed, totalDuration);
const progress = Math.min(1, currentPlaybackTime / totalDuration);
const pct = Math.round(progress * 100);
exportPercent.textContent = `${pct}%`;
exportProgressBarFill.style.width = `${pct}%`;
const remaining = Math.max(0, totalDuration - currentPlaybackTime);
exportEta.textContent = `Verbleibend: ${formatTime(remaining)}`;
exportStatusText.textContent = `Rendert Video (${pct}%)...`;
state.analyser.getByteFrequencyData(freqArray);
renderFrame(ctx, state.width, state.height, freqArray, currentPlaybackTime, totalDuration, state.config);
if (elapsed < totalDuration + 0.3) {
exportAnimFrame = requestAnimationFrame(renderExportLoop);
}
};
exportSourceNode.onended = () => {
setTimeout(() => {
if (exportMediaRecorder && exportMediaRecorder.state !== 'inactive') {
exportMediaRecorder.stop();
}
}, 400);
};
exportMediaRecorder.start(250);
exportSourceNode.start(0);
exportAnimFrame = requestAnimationFrame(renderExportLoop);
} catch (err) {
console.error('Export Error:', err);
alert('Fehler beim Video-Export: ' + err.message);
cancelExport();
}
}
function cancelExport() {
state.isExporting = false;
if (exportAnimFrame) cancelAnimationFrame(exportAnimFrame);
if (exportSourceNode) {
try { exportSourceNode.stop(); } catch (e) {}
exportSourceNode = null;
}
if (exportMediaRecorder && exportMediaRecorder.state !== 'inactive') {
try { exportMediaRecorder.stop(); } catch (e) {}
exportMediaRecorder = null;
}
exportProgressCard.classList.add('hidden');
btnStartExport.classList.remove('hidden');
}
// --- Event Listeners & Binding ---
function setupEvents() {
audioDropzone.addEventListener('click', () => audioFileInput.click());
imageDropzone.addEventListener('click', () => imageFileInput.click());
audioFileInput.addEventListener('change', (e) => {
if (e.target.files.length) loadAudioFile(e.target.files[0]);
});
imageFileInput.addEventListener('change', (e) => {
if (e.target.files.length) loadImageFile(e.target.files[0]);
});
[audioDropzone, imageDropzone].forEach(dz => {
dz.addEventListener('dragover', (e) => { e.preventDefault(); dz.classList.add('dragover'); });
dz.addEventListener('dragleave', () => dz.classList.remove('dragover'));
dz.addEventListener('drop', (e) => {
e.preventDefault();
dz.classList.remove('dragover');
if (dz === audioDropzone && e.dataTransfer.files.length) {
loadAudioFile(e.dataTransfer.files[0]);
} else if (dz === imageDropzone && e.dataTransfer.files.length) {
loadImageFile(e.dataTransfer.files[0]);
}
});
});
btnPlayPause.addEventListener('click', () => {
if (state.isPlaying) {
pausePlayback();
} else {
startPlayback(state.pauseOffset || 0);
}
});
btnRewind.addEventListener('click', () => {
const cur = getCurrentPlaybackTime();
const target = Math.max(0, cur - 5);
seekScrubber.value = target;
currentTimeEl.textContent = formatTime(target);
const pct = (target / (state.duration || 1)) * 100;
const bar = document.getElementById('timelineProgressBar');
if (bar) bar.style.width = `${pct}%`;
if (state.isPlaying) {
startPlayback(target);
} else {
state.pauseOffset = target;
}
});
btnForward.addEventListener('click', () => {
const cur = getCurrentPlaybackTime();
const target = Math.min(state.duration || 0, cur + 5);
seekScrubber.value = target;
currentTimeEl.textContent = formatTime(target);
const pct = (target / (state.duration || 1)) * 100;
const bar = document.getElementById('timelineProgressBar');
if (bar) bar.style.width = `${pct}%`;
if (state.isPlaying) {
startPlayback(target);
} else {
state.pauseOffset = target;
}
});
btnLoop.addEventListener('click', () => {
state.loop = !state.loop;
btnLoop.classList.toggle('active', state.loop);
if (state.audioSource) state.audioSource.loop = state.loop;
});
seekScrubber.addEventListener('input', (e) => {
const t = parseFloat(e.target.value);
currentTimeEl.textContent = formatTime(t);
const pct = (t / (state.duration || 1)) * 100;
const bar = document.getElementById('timelineProgressBar');
if (bar) bar.style.width = `${pct}%`;
if (state.isPlaying) {
startPlayback(t);
} else {
state.pauseOffset = t;
}
});
volumeSlider.addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
if (state.gainNode) state.gainNode.gain.value = v;
});
aspectBtns.forEach(btn => {
btn.addEventListener('click', () => setAspectRatio(btn.dataset.aspect));
});
toggleSafeZone.addEventListener('change', (e) => {
safeZoneOverlay.classList.toggle('hidden', !e.target.checked);
});
toggleAlphaGrid.addEventListener('change', (e) => {
canvasWrapper.classList.toggle('alpha-bg', e.target.checked);
});
quickPresetSelect.addEventListener('change', (e) => {
applyPreset(e.target.value);
});
// Preset Library Cards
document.querySelectorAll('.preset-select-btn').forEach(btn => {
btn.addEventListener('click', () => {
applyPreset(btn.dataset.preset);
quickPresetSelect.value = btn.dataset.preset;
});
});
// 1-Click Palette Chips
document.querySelectorAll('.palette-chip').forEach(chip => {
chip.addEventListener('click', () => {
document.getElementById('colorGrad1').value = chip.dataset.p1;
document.getElementById('colorGrad2').value = chip.dataset.p2;
document.getElementById('colorGrad3').value = chip.dataset.p3;
document.getElementById('colorParticle').value = chip.dataset.p1;
document.getElementById('colorGrid').value = chip.dataset.p2;
updateConfigFromUI();
});
});
// Tabs
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
tabBtns.forEach(b => b.classList.remove('active'));
tabPanels.forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById(btn.dataset.tab).classList.add('active');
});
});
// Two-way UI sync
document.querySelectorAll('.sidebar-content input, .sidebar-content select').forEach(el => {
el.addEventListener('input', updateConfigFromUI);
el.addEventListener('change', updateConfigFromUI);
});
// Preset Manager
btnSavePreset.addEventListener('click', () => {
const name = prompt('Preset Name eingeben:', 'Mein Custom Style');
if (!name) return;
const key = 'custom_' + Date.now();
const presetObj = Object.assign({}, state.config, { name });
const saved = JSON.parse(localStorage.getItem('viz_user_presets') || '{}');
saved[key] = presetObj;
localStorage.setItem('viz_user_presets', JSON.stringify(saved));
alert(`Preset "${name}" erfolgreich gespeichert!`);
});
btnExportPresetJson.addEventListener('click', () => {
const dataStr = 'data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(state.config, null, 2));
const a = document.createElement('a');
a.href = dataStr;
a.download = `viz_preset_${(state.config.name || 'custom').toLowerCase().replace(/\s+/g, '_')}.json`;
a.click();
});
importPresetInput.addEventListener('change', (e) => {
if (e.target.files.length) {
const reader = new FileReader();
reader.onload = (evt) => {
try {
const imported = JSON.parse(evt.target.result);
applyPreset(imported);
alert('Preset erfolgreich geladen!');
} catch (err) {
alert('Ungültiges Preset JSON');
}
};
reader.readAsText(e.target.files[0]);
}
});
btnStartExport.addEventListener('click', startExport);
headerExportBtn.addEventListener('click', () => {
document.querySelector('[data-tab="tab-export"]').click();
});
btnCancelExport.addEventListener('click', cancelExport);
window.addEventListener('keydown', (e) => {
if (e.code === 'Space' && e.target.tagName !== 'INPUT') {
e.preventDefault();
btnPlayPause.click();
}
});
}
// --- Initialize App ---
function init() {
setAspectRatio('16:9');
applyPreset('trap_nation');
setupEvents();
state.isPlaying = true;
iconPlay.classList.add('hidden');
iconPause.classList.remove('hidden');
requestAnimationFrame(mainLoop);
}
init();
})();