liminal-sessions / main.js
Severian's picture
Upload 32 files
6a03d7f verified
import * as THREE from 'https://unpkg.com/three@0.136.0/build/three.module.js';
import { AudioLoader, AudioListener, Audio, AudioAnalyser } from 'https://unpkg.com/three@0.136.0/build/three.module.js';
import * as dat from 'https://cdn.jsdelivr.net/npm/dat.gui/build/dat.gui.module.js';
console.log('Script started');
// Cleanup function
function cleanupPreviousElements() {
const elementsToRemove = [
'#songSelect', '#playPause', '#volume',
'button', 'select', '.dg.main', 'canvas', '.controls-container',
'#volumeControl', '#audioControls', '.audio-control'
];
elementsToRemove.forEach(selector => {
document.querySelectorAll(selector).forEach(element => {
if (selector !== '#presetContainer') {
element.remove();
}
});
});
document.querySelectorAll('div').forEach(div => {
if ((div.id?.includes('control') ||
div.className?.includes('control') ||
div.id?.includes('audio') ||
div.className?.includes('audio')) &&
div.id !== 'presetContainer') {
div.remove();
}
});
}
cleanupPreviousElements();
console.log('Cleanup completed');
// Fixed position GUI and presets
const guiAndPresetsStyleFix = document.createElement('style');
guiAndPresetsStyleFix.textContent = `
/* Fixed position GUI spheres */
.dg.main {
position: absolute !important;
top: 10px !important;
right: 10px !important;
z-index: 1000 !important;
}
#presetContainer {
position: fixed !important;
top: 10px !important;
left: 10px !important;
z-index: 1000 !important;
display: flex !important;
gap: 10px !important;
}
#presetContainer input[type="text"] {
flex: 1 1 auto;
min-width: 150px;
padding: 5px;
border-radius: 3px;
}
#presetContainer select,
#presetContainer button {
flex: 0 0 auto;
padding: 5px;
border-radius: 3px;
}
`;
document.head.appendChild(guiAndPresetsStyleFix);
// Scene setup
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = 2.5;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
console.log('Scene and renderer initialized');
// Audio setup
let audioContext;
let analyser;
let audioElement;
let sourceNode = null;
let micStream;
let micSource = null;
// Audio Controls Container
const controls = document.createElement('div');
controls.style.cssText = 'position: absolute; top: 10px; right: 310px; z-index: 1000; background: rgba(0,0,0,0.7); padding: 10px; border-radius: 5px;';
document.body.appendChild(controls);
const audioControls = document.createElement('div');
audioControls.style.cssText = 'display: flex; align-items: center; gap: 10px;';
controls.appendChild(audioControls);
const songSelect = document.createElement('select');
songSelect.style.cssText = 'padding: 5px; border-radius: 3px; background: #333; color: white; border: 1px solid #666;';
audioControls.appendChild(songSelect);
const playPause = document.createElement('button');
playPause.textContent = 'Play';
playPause.style.cssText = 'padding: 5px 15px; border-radius: 3px; background: #444; color: white; border: 1px solid #666;';
audioControls.appendChild(playPause);
const volumeControl = document.createElement('input');
volumeControl.type = 'range';
volumeControl.min = 0;
volumeControl.max = 1;
volumeControl.step = 0.1;
volumeControl.value = 0.5;
volumeControl.style.width = '100px';
audioControls.appendChild(volumeControl);
const timelineControl = document.createElement('input');
timelineControl.type = 'range';
timelineControl.min = 0;
timelineControl.max = 100;
timelineControl.step = 1;
timelineControl.value = 0;
timelineControl.style.width = '200px';
timelineControl.style.marginLeft = '10px';
audioControls.appendChild(timelineControl);
const inputToggle = document.createElement('button');
inputToggle.textContent = 'Use Mic';
inputToggle.style.cssText = 'padding: 5px 15px; border-radius: 3px; background: #444; color: white; border: 1px solid #666; margin-left: 10px;';
audioControls.appendChild(inputToggle);
let usingMic = false;
inputToggle.onclick = toggleInput;
playPause.onclick = togglePlay;
songSelect.onchange = changeSong;
console.log('Controls created');
// Noise generator
const noise = {
p: new Array(256).fill(0).map((_, i) => i),
perm: new Array(512),
init() {
for (let i = this.p.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[this.p[i], this.p[j]] = [this.p[j], this.p[i]];
}
for (let i = 0; i < 512; i++) {
this.perm[i] = this.p[i & 255];
}
},
fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); },
lerp(t, a, b) { return a + t * (b - a); },
grad(hash, x, y, z) {
const h = hash & 15;
const u = h < 8 ? x : y;
const v = h < 4 ? y : h == 12 || h == 14 ? x : z;
return ((h & 1) == 0 ? u : -u) + ((h & 2) == 0 ? v : -v);
},
noise3D(x, y, z) {
const X = Math.floor(x) & 255;
const Y = Math.floor(y) & 255;
const Z = Math.floor(z) & 255;
x -= Math.floor(x);
y -= Math.floor(y);
z -= Math.floor(z);
const u = this.fade(x);
const v = this.fade(y);
const w = this.fade(z);
const A = this.perm[X] + Y;
const AA = this.perm[A] + Z;
const AB = this.perm[A + 1] + Z;
const B = this.perm[X + 1] + Y;
const BA = this.perm[B] + Z;
const BB = this.perm[B + 1] + Z;
return this.lerp(w, this.lerp(v, this.lerp(u, this.grad(this.perm[AA], x, y, z),
this.grad(this.perm[BA], x-1, y, z)),
this.lerp(u, this.grad(this.perm[AB], x, y-1, z),
this.grad(this.perm[BB], x-1, y-1, z))),
this.lerp(v, this.lerp(u, this.grad(this.perm[AA+1], x, y, z-1),
this.grad(this.perm[BA+1], x-1, y, z-1)),
this.lerp(u, this.grad(this.perm[AB+1], x, y-1, z-1),
this.grad(this.perm[BB+1], x-1, y-1, z-1))));
}
};
noise.init();
console.log('Noise initialized');
// Beat manager
const beatManager = {
currentWaveRadius: 0,
waveStrength: 0,
isWaveActive: false,
triggerWave(rangeEnergy) {
const maxEnergy = 255;
const energyExcess = rangeEnergy - 200;
this.waveStrength = (energyExcess / (maxEnergy - 200)) * 20.0;
this.currentWaveRadius = 0;
this.isWaveActive = true;
},
update(deltaTime) {
if (this.isWaveActive) {
this.currentWaveRadius += deltaTime * 1.0;
this.waveStrength *= 0.98;
if (this.currentWaveRadius > 1.0 || this.waveStrength < 0.1) {
this.isWaveActive = false;
}
}
},
getWaveForce(position) {
if (!this.isWaveActive) return 0;
const distanceFromCenter = position.length();
const distanceFromWave = Math.abs(distanceFromCenter - this.currentWaveRadius);
if (distanceFromWave < 0.1) {
return this.waveStrength * Math.exp(-distanceFromWave * 10);
}
return 0;
}
};
async function toggleInput() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
}
await audioContext.resume();
if (usingMic) {
usingMic = false;
inputToggle.textContent = 'Use Mic';
songSelect.disabled = false;
playPause.disabled = false;
volumeControl.disabled = false;
timelineControl.disabled = false;
if (micSource) {
micSource.disconnect();
micSource = null;
}
if (sourceNode) {
sourceNode.connect(analyser);
}
analyser.connect(audioContext.destination);
} else {
usingMic = true;
inputToggle.textContent = 'Use Player';
songSelect.disabled = true;
playPause.disabled = true;
volumeControl.disabled = true;
timelineControl.disabled = true;
try {
micStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: false
}
});
if (sourceNode) {
sourceNode.disconnect();
}
micSource = audioContext.createMediaStreamSource(micStream);
micSource.connect(analyser);
analyser.disconnect(audioContext.destination);
console.log('Microphone is active');
} catch (error) {
console.error("Microphone access failed:", error.name, error.message);
usingMic = false;
inputToggle.textContent = 'Use Mic';
}
}
}
async function initAudio() {
try {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
}
if (!usingMic) {
if (!audioElement) {
audioElement = document.createElement('audio');
audioElement.crossOrigin = "anonymous";
audioElement.volume = volumeControl.value;
}
try {
if (!sourceNode) {
sourceNode = audioContext.createMediaElementSource(audioElement);
sourceNode.connect(analyser);
analyser.connect(audioContext.destination);
} else {
sourceNode.disconnect();
sourceNode.connect(analyser);
}
} catch (error) {
console.error("Failed to connect audio element to analyser:", error.name, error.message);
}
try {
const response = await fetch('Songs/');
const text = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/html');
const files = Array.from(doc.querySelectorAll('a'))
.map(a => decodeURIComponent(a.href))
.filter(href => href.match(/\.(mp3|wav|ogg)$/i))
.map(href => {
const fileName = href.split('/').pop();
return {
path: `Songs/${fileName}`,
name: fileName.replace(/\.(mp3|wav|ogg)$/i, '')
};
});
songSelect.innerHTML = '';
files.forEach(file => {
const option = document.createElement('option');
option.value = file.path;
option.textContent = file.name;
songSelect.appendChild(option);
});
if (files.length > 0 && !audioElement.src) {
audioElement.src = files[0].path;
}
} catch (error) {
console.error("Failed to load song list:", error);
}
volumeControl.oninput = e => {
if (audioElement) {
audioElement.volume = e.target.value;
}
};
setupTimelineControl();
}
console.log('Audio initialized');
} catch (error) {
console.error("Audio initialization failed:", error);
}
}
function getAudioData(sphere) {
if (!analyser || (!audioContext && !usingMic)) return {
average: 0,
frequencies: new Float32Array(),
peakDetected: false,
rangeEnergy: 0
};
try {
const frequencies = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(frequencies);
const gainMultiplier = sphere.params.gainMultiplier;
frequencies.forEach((value, index) => {
frequencies[index] = Math.min(value * gainMultiplier, 255);
});
const frequencyToIndex = (frequency) => Math.round(frequency / (audioContext.sampleRate / 2) * analyser.frequencyBinCount);
const minFreqIndex = frequencyToIndex(sphere.params.minFrequency);
const maxFreqIndex = frequencyToIndex(sphere.params.maxFrequency);
const frequencyRange = frequencies.slice(minFreqIndex, maxFreqIndex + 1);
const rangeEnergy = frequencyRange.reduce((a, b) => a + b, 0) / frequencyRange.length;
const minFreqBeatIndex = frequencyToIndex(sphere.params.minFrequencyBeat); // Nové pásmo pro beaty
const maxFreqBeatIndex = frequencyToIndex(sphere.params.maxFrequencyBeat);
const frequencyRangeBeat = frequencies.slice(minFreqBeatIndex, maxFreqBeatIndex + 1);
const rangeEnergyBeat = frequencyRangeBeat.reduce((a, b) => a + b, 0) / frequencyRangeBeat.length;
sphere.peakDetection.energyHistory.push(rangeEnergy);
if (sphere.peakDetection.energyHistory.length > sphere.peakDetection.historyLength) {
sphere.peakDetection.energyHistory.shift();
}
const averageEnergy = sphere.peakDetection.energyHistory.reduce((a, b) => a + b, 0) /
sphere.peakDetection.energyHistory.length;
const now = performance.now();
const peakDetected = rangeEnergy > averageEnergy * sphere.params.peakSensitivity &&
now - sphere.peakDetection.lastPeakTime > sphere.peakDetection.minTimeBetweenPeaks;
if (peakDetected) {
sphere.peakDetection.lastPeakTime = now;
console.log(`Sphere ${sphere.index + 1} PEAK DETECTED! Energy: ${rangeEnergy}, Average: ${averageEnergy}`);
}
return {
average: rangeEnergy / 255,
frequencies,
peakDetected,
rangeEnergy: rangeEnergy,
rangeEnergyBeat: rangeEnergyBeat
};
} catch (error) {
console.error("Audio analysis failed:", error);
return {
average: 0,
frequencies: new Float32Array(),
peakDetected: false,
rangeEnergy: 0
};
}
}
function updateTimeline() {
if (audioElement && !audioElement.paused && audioElement.duration) {
const percent = (audioElement.currentTime / audioElement.duration) * 100;
timelineControl.value = percent;
}
}
function setupTimelineControl() {
audioElement.addEventListener('loadedmetadata', () => {
timelineControl.value = 0;
console.log('Song duration:', audioElement.duration);
});
audioElement.addEventListener('timeupdate', updateTimeline);
timelineControl.addEventListener('input', (e) => {
if (audioElement.duration) {
const time = (e.target.value / 100) * audioElement.duration;
audioElement.currentTime = time;
}
});
}
function togglePlay() {
if (usingMic) return;
if (audioContext.state === 'suspended') {
audioContext.resume();
}
if (!sourceNode && audioElement) {
try {
sourceNode = audioContext.createMediaElementSource(audioElement);
sourceNode.connect(analyser);
analyser.connect(audioContext.destination);
} catch (error) {
console.error("Audio connection failed:", error);
return;
}
}
if (audioElement.paused) {
audioElement.play()
.then(() => playPause.textContent = 'Pause')
.catch(error => console.error("Playback failed:", error));
} else {
audioElement.pause();
playPause.textContent = 'Play';
}
}
function changeSong() {
if (audioContext.state === 'suspended') {
audioContext.resume();
}
audioElement.src = songSelect.value;
audioElement.load();
if (!sourceNode) {
try {
sourceNode = audioContext.createMediaElementSource(audioElement);
sourceNode.connect(analyser);
analyser.connect(audioContext.destination);
} catch (error) {
console.error("Audio connection failed:", error);
return;
}
}
audioElement.play()
.then(() => {
playPause.textContent = 'Pause';
console.log("Playback started");
})
.catch(error => console.error("Playback failed:", error));
timelineControl.value = 0;
}
function generateNewNoiseScale(params, lastNoiseScale) {
if (!params.dynamicNoiseScale) {
return params.noiseScale;
}
let { minNoiseScale, maxNoiseScale, noiseStep } = params;
// --- PŘIDANÁ POJISTKA A DROBNÉ LOGY ---
if (minNoiseScale >= maxNoiseScale) {
console.warn(`Fixing minNoiseScale (${minNoiseScale}) >= maxNoiseScale (${maxNoiseScale}).`);
maxNoiseScale = minNoiseScale + 0.1; // Natvrdo posunout, aby byl rozdíl aspoň 0.1
}
let range = maxNoiseScale - minNoiseScale;
if (range < 0.1) {
console.warn(`Range < 0.1 => Forcing minimal range = 0.1`);
range = 0.1;
maxNoiseScale = minNoiseScale + range;
}
if (noiseStep > range) {
console.warn(`noiseStep (${noiseStep}) > range (${range}) => Forcing noiseStep = range / 2`);
noiseStep = range / 2;
}
// LastNoiseScale valid range
lastNoiseScale = Math.max(minNoiseScale, Math.min(lastNoiseScale, maxNoiseScale));
const stepsUp = Math.floor((maxNoiseScale - lastNoiseScale) / noiseStep);
const stepsDown = Math.floor((lastNoiseScale - minNoiseScale) / noiseStep);
if (stepsUp === 0 && stepsDown === 0) {
return lastNoiseScale;
}
const direction = Math.random() < 0.5 && stepsDown > 0 ? -1 : 1;
const steps = direction === 1
? Math.floor(Math.random() * (stepsUp + 1))
: Math.floor(Math.random() * (stepsDown + 1));
let newValue = lastNoiseScale + direction * steps * noiseStep;
newValue = Math.max(minNoiseScale, Math.min(newValue, maxNoiseScale));
return newValue;
}
// Reinit particles
function reinitializeParticlesForSphere(sphere, sphereParams, sphereGeometry) {
console.log(`Reinitializing sphere ${sphere.index + 1} with ${sphereParams.particleCount} particles`);
const newPositions = new Float32Array(sphereParams.particleCount * 3);
const newColors = new Float32Array(sphereParams.particleCount * 3);
const newVelocities = new Float32Array(sphereParams.particleCount * 3);
const newBasePositions = new Float32Array(sphereParams.particleCount * 3);
const newLifetimes = new Float32Array(sphereParams.particleCount);
const newMaxLifetimes = new Float32Array(sphereParams.particleCount);
const newBeatEffects = new Float32Array(sphereParams.particleCount);
for (let i = 0; i < sphereParams.particleCount; i++) {
const i3 = i * 3;
const radius = THREE.MathUtils.lerp(0, sphereParams.sphereRadius, sphereParams.innerSphereRadius);
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const r = Math.cbrt(Math.random()) * radius;
const x = r * Math.sin(phi) * Math.cos(theta);
const y = r * Math.sin(phi) * Math.sin(theta);
const z = r * Math.cos(phi);
newPositions[i3] = x;
newPositions[i3 + 1] = y;
newPositions[i3 + 2] = z;
newBasePositions[i3] = x;
newBasePositions[i3 + 1] = y;
newBasePositions[i3 + 2] = z;
newVelocities[i3] = 0;
newVelocities[i3 + 1] = 0;
newVelocities[i3 + 2] = 0;
const lt = Math.random() * sphereParams.particleLifetime;
newLifetimes[i] = lt;
newMaxLifetimes[i] = lt;
newBeatEffects[i] = 0;
}
sphereGeometry.setAttribute('position', new THREE.BufferAttribute(newPositions, 3));
sphereGeometry.setAttribute('color', new THREE.BufferAttribute(newColors, 3));
updateColorsForSphere(sphereParams, sphereGeometry, newColors);
return {
newPositions,
newColors,
newVelocities,
newBasePositions,
newLifetimes,
newMaxLifetimes,
newBeatEffects
};
}
function updateColorsForSphere(sphereParams, sphereGeometry, sphereColors) {
const color1 = new THREE.Color(sphereParams.colorStart);
const color2 = new THREE.Color(sphereParams.colorEnd);
for (let i = 0; i < sphereParams.particleCount; i++) {
const t = i / sphereParams.particleCount;
sphereColors[i * 3] = color1.r * (1 - t) + color2.r * t;
sphereColors[i * 3 + 1] = color1.g * (1 - t) + color2.g * t;
sphereColors[i * 3 + 2] = color1.b * (1 - t) + color2.b * t;
}
sphereGeometry.attributes.color.needsUpdate = true;
}
// Preset management
const presets = JSON.parse(localStorage.getItem('presets')) || {}; // Uložené presety
const defaultParams = []; // Pro ukládání výchozích hodnot každé sféry
// HTML elements presets
let presetContainer = document.querySelector('#presetContainer');
let presetInput, saveButton, resetButton, presetSelect, deleteButton, exportButton, importButton;
if (!presetContainer) {
presetContainer = document.createElement('div');
presetContainer.id = 'presetContainer';
presetContainer.style.cssText = `
position: fixed !important;
top: 10px !important;
left: 10px !important;
z-index: 1000 !important;
display: flex !important;
gap: 10px !important;
`;
presetInput = document.createElement('input');
presetInput.type = 'text';
presetInput.placeholder = 'Preset name';
presetInput.style.cssText = 'padding: 5px; border-radius: 3px;';
saveButton = document.createElement('button');
saveButton.textContent = 'Save';
saveButton.style.cssText = 'padding: 5px 10px; border-radius: 3px; background: #444; color: white; border: 1px solid #666;';
resetButton = document.createElement('button');
resetButton.textContent = 'Reset';
resetButton.style.cssText = 'padding: 5px 10px; border-radius: 3px; background: #444; color: white; border: 1px solid #666;';
presetSelect = document.createElement('select');
presetSelect.style.cssText = 'padding: 5px; border-radius: 3px; background: #333; color: white; border: 1px solid #666;';
deleteButton = document.createElement('button');
deleteButton.textContent = 'Delete';
deleteButton.style.cssText = 'padding: 5px 10px; border-radius: 3px; background: #444; color: white; border: 1px solid #666;';
exportButton = document.createElement('button');
exportButton.textContent = 'Export Presets';
exportButton.style.cssText = 'padding: 5px 10px; border-radius: 3px; background: #444; color: white; border: 1px solid #666;';
importButton = document.createElement('button');
importButton.textContent = 'Import Presets';
importButton.style.cssText = 'padding: 5px 10px; border-radius: 3px; background: #444; color: white; border: 1px solid #666;';
presetContainer.appendChild(presetInput);
presetContainer.appendChild(saveButton);
presetContainer.appendChild(resetButton);
presetContainer.appendChild(deleteButton);
presetContainer.appendChild(exportButton);
presetContainer.appendChild(importButton);
presetContainer.appendChild(presetSelect);
document.body.appendChild(presetContainer);
}
// Save presets logic
saveButton.onclick = () => {
const presetName = presetInput.value.trim();
if (!presetName) return;
presets[presetName] = spheres.map(sphere => JSON.parse(JSON.stringify(sphere.params)));
localStorage.setItem('presets', JSON.stringify(presets));
updatePresetOptions();
};
// Reset logic
resetButton.onclick = () => {
spheres.forEach((sphere, index) => {
const previousParticleCount = sphere.params.particleCount; // Uložíme původní počet částic
Object.assign(sphere.params, defaultParams[index]);
sphere.particleSystem.visible = sphere.params.enabled;
if (sphere.params.particleCount !== previousParticleCount) {
const {
newPositions,
newColors,
newVelocities,
newBasePositions,
newLifetimes,
newMaxLifetimes,
newBeatEffects
} = reinitializeParticlesForSphere(sphere, sphere.params, sphere.geometry);
sphere.positions = newPositions;
sphere.colors = newColors;
sphere.velocities = newVelocities;
sphere.basePositions = newBasePositions;
sphere.lifetimes = newLifetimes;
sphere.maxLifetimes = newMaxLifetimes;
sphere.beatEffects = newBeatEffects;
sphere.geometry.attributes.position.needsUpdate = true;
sphere.geometry.attributes.color.needsUpdate = true;
}
const sphereFolder = mainGui.__folders[`Sphere ${index + 1}`];
if (sphereFolder) {
sphereFolder.__controllers.forEach(controller => controller.updateDisplay());
}
});
console.log("Parameters reset to default values");
mainGui.updateDisplay();
};
// Delete preset logic
deleteButton.onclick = () => {
const presetName = presetSelect.value;
if (!presetName) {
console.warn('Žádný preset není vybraný.');
return;
}
const sure = confirm(`Skutečně smazat preset "${presetName}"?`);
if (!sure) return;
delete presets[presetName];
localStorage.setItem('presets', JSON.stringify(presets));
updatePresetOptions();
presetSelect.value = '';
presetInput.value = '';
console.log(`Preset "${presetName}" byl smazán.`);
};
// Export presets
exportButton.onclick = () => {
const presetData = JSON.stringify(presets, null, 2);
const blob = new Blob([presetData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'particula_presets.json';
link.click();
URL.revokeObjectURL(url);
};
// Import presets
importButton.onclick = () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'application/json';
fileInput.onchange = (event) => {
const file = event.target.files[0];
const reader = new FileReader();
reader.onload = (e) => {
const importedPresets = JSON.parse(e.target.result);
Object.assign(presets, importedPresets);
localStorage.setItem('presets', JSON.stringify(presets));
updatePresetOptions();
console.log('Presety byly úspěšně importovány.');
};
reader.readAsText(file);
};
fileInput.click();
};
// Upload presets logic
presetSelect.onchange = () => {
const presetName = presetSelect.value;
if (!presetName) return;
const preset = presets[presetName];
if (!preset) return;
spheres.forEach((sphere, index) => {
const previousParticleCount = sphere.params.particleCount; // Původní počet částic
Object.assign(sphere.params, preset[index]);
if (!('minFrequencyBeat' in sphere.params)) {
sphere.params.minFrequencyBeat = sphere.params.minFrequency;
}
if (!('maxFrequencyBeat' in sphere.params)) {
sphere.params.maxFrequencyBeat = sphere.params.maxFrequency;
}
if (sphere.params.minNoiseScale >= sphere.params.maxNoiseScale) {
console.warn(`Preset fix: minNoiseScale (${sphere.params.minNoiseScale}) >= maxNoiseScale (${sphere.params.maxNoiseScale}).`);
sphere.params.maxNoiseScale = sphere.params.minNoiseScale + 0.1;
}
if (sphere.params.particleCount !== previousParticleCount) {
const {
newPositions,
newColors,
newVelocities,
newBasePositions,
newLifetimes,
newMaxLifetimes,
newBeatEffects
} = reinitializeParticlesForSphere(sphere, sphere.params, sphere.geometry);
sphere.positions = newPositions;
sphere.colors = newColors;
sphere.velocities = newVelocities;
sphere.basePositions = newBasePositions;
sphere.lifetimes = newLifetimes;
sphere.maxLifetimes = newMaxLifetimes;
sphere.beatEffects = newBeatEffects;
sphere.geometry.attributes.position.needsUpdate = true;
sphere.geometry.attributes.color.needsUpdate = true;
}
sphere.particleSystem.visible = sphere.params.enabled;
});
mainGui.updateDisplay();
};
// Roll presets
function updatePresetOptions() {
while (presetSelect.firstChild) {
presetSelect.removeChild(presetSelect.firstChild);
}
const defaultOption = document.createElement('option');
defaultOption.textContent = 'Select preset';
defaultOption.value = '';
presetSelect.appendChild(defaultOption);
Object.keys(presets).forEach(name => {
const option = document.createElement('option');
option.textContent = name;
option.value = name;
presetSelect.appendChild(option);
});
}
// Main GUI panel
const mainGui = new dat.GUI();
// FOG PARAMS
const fogParams = {
enabled: true,
color: '#000000',
near: 2.7,
far: 3.7,
};
// After fog update
function updateFog() {
if (!fogParams.enabled) {
scene.fog = null;
} else {
const color = new THREE.Color(fogParams.color);
scene.fog = new THREE.Fog(color, fogParams.near, fogParams.far);
}
renderer.render(scene, camera); // Překreslení scény
}
// Fog init
if (fogParams.enabled) {
updateFog();
}
// Adding to main GUI
mainGui.add(fogParams, 'enabled').name('Fog Enabled').onChange(updateFog);
mainGui.addColor(fogParams, 'color').name('Fog Color').onChange(updateFog);
mainGui.add(fogParams, 'near', 0.1, 5, 0.1).name('Fog Near').onChange(updateFog);
mainGui.add(fogParams, 'far', 0.1, 5, 0.1).name('Fog Far').onChange(updateFog);
// Main GUI particleCount
mainGui.add({
globalParticleCount: 20000
}, 'globalParticleCount', 1000, 100000).step(1000).onChange(value => {
spheres.forEach((sphere, index) => {
sphere.params.particleCount = value;
const {
newPositions,
newColors,
newVelocities,
newBasePositions,
newLifetimes,
newMaxLifetimes,
newBeatEffects
} = reinitializeParticlesForSphere(
sphere, sphere.params, sphere.geometry
);
sphere.positions = newPositions;
sphere.colors = newColors;
sphere.velocities = newVelocities;
sphere.basePositions = newBasePositions;
sphere.lifetimes = newLifetimes;
sphere.maxLifetimes = newMaxLifetimes;
sphere.beatEffects = newBeatEffects;
sphere.geometry.attributes.position.needsUpdate = true;
sphere.geometry.attributes.color.needsUpdate = true;
const sphereFolder = mainGui.__folders[`Sphere ${index + 1}`];
if (sphereFolder) {
const particleCountController = sphereFolder.__controllers.find(controller => controller.property === 'particleCount');
if (particleCountController) {
particleCountController.updateDisplay();
}
}
});
});
const spheres = [];
function createSphereVisualization(index) {
// Spheres default frequencies
const defaultFrequencies = [
{ minFrequency: 20, maxFrequency: 80 }, // Sub-bass
{ minFrequency: 120, maxFrequency: 250 }, // Bass
{ minFrequency: 250, maxFrequency: 800 }, // Mid
{ minFrequency: 1000, maxFrequency: 4000 }, // High mid
{ minFrequency: 5000, maxFrequency: 10000 } // High
];
const sphereParams = {
enabled: index === 0,
sphereRadius: 1.0,
innerSphereRadius: 0.25,
rotationSpeed: 0.001,
rotationSpeedMin: 0,
rotationSpeedMax: 0.065,
rotationSmoothness: 0.3,
particleCount: 20000,
particleSize: 0.003,
particleLifetime: 3.0,
minFrequency: defaultFrequencies[index]?.minFrequency || 0,
maxFrequency: defaultFrequencies[index]?.maxFrequency || 22050,
minFrequencyBeat: defaultFrequencies[index]?.minFrequency || 0,
maxFrequencyBeat: defaultFrequencies[index]?.maxFrequency || 22050,
noiseScale: 4.0,
dynamicNoiseScale: true,
minNoiseScale: 0.5,
maxNoiseScale: 5.0,
noiseStep: 0.2,
noiseSpeed: 0.1,
turbulenceStrength: 0.005,
colorStart: '#ff3366',
colorEnd: '#3366ff',
volumeChangeThreshold: 0.1,
peakSensitivity: 1.1,
beatThreshold: 200,
baseWaveStrength: 20.0,
beatStrength: 0.01,
gainMultiplier: 1
};
const sphereGeometry = new THREE.BufferGeometry();
const spherePositions = new Float32Array(sphereParams.particleCount * 3);
const sphereColors = new Float32Array(sphereParams.particleCount * 3);
const velocities = new Float32Array(sphereParams.particleCount * 3);
const basePositions = new Float32Array(sphereParams.particleCount * 3);
const lifetimes = new Float32Array(sphereParams.particleCount);
const maxLifetimes = new Float32Array(sphereParams.particleCount);
const beatEffects = new Float32Array(sphereParams.particleCount);
// Init particles
for (let i = 0; i < sphereParams.particleCount; i++) {
const i3 = i * 3;
const radius = THREE.MathUtils.lerp(0, sphereParams.sphereRadius, sphereParams.innerSphereRadius);
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const r = Math.cbrt(Math.random()) * radius;
const x = r * Math.sin(phi) * Math.cos(theta);
const y = r * Math.sin(phi) * Math.sin(theta);
const z = r * Math.cos(phi);
spherePositions[i3] = x;
spherePositions[i3 + 1] = y;
spherePositions[i3 + 2] = z;
basePositions[i3] = x;
basePositions[i3 + 1] = y;
basePositions[i3 + 2] = z;
velocities[i3] = 0;
velocities[i3 + 1] = 0;
velocities[i3 + 2] = 0;
const lt = Math.random() * sphereParams.particleLifetime;
lifetimes[i] = lt;
maxLifetimes[i] = lt;
beatEffects[i] = 0;
}
sphereGeometry.setAttribute('position', new THREE.BufferAttribute(spherePositions, 3));
sphereGeometry.setAttribute('color', new THREE.BufferAttribute(sphereColors, 3));
const sphereMaterial = new THREE.PointsMaterial({
size: sphereParams.particleSize,
vertexColors: true,
transparent: true,
opacity: 0.8,
blending: THREE.AdditiveBlending,
fog: true
});
const sphereParticleSystem = new THREE.Points(sphereGeometry, sphereMaterial);
scene.add(sphereParticleSystem);
// Sphere visibility `enabled`
sphereParticleSystem.visible = sphereParams.enabled;
const sphere = {
index: index,
params: sphereParams,
geometry: sphereGeometry,
colors: sphereColors,
material: sphereMaterial,
particleSystem: sphereParticleSystem,
positions: spherePositions,
velocities: velocities,
basePositions: basePositions,
lifetimes: lifetimes,
maxLifetimes: maxLifetimes,
beatEffects: beatEffects,
lastNoiseScale: sphereParams.noiseScale,
lastValidVolume: 0,
lastRotationSpeed: 0
};
sphere.peakDetection = {
energyHistory: [],
historyLength: 30,
lastPeakTime: 0,
minTimeBetweenPeaks: 200
};
// Colors update
updateColorsForSphere(sphereParams, sphereGeometry, sphereColors);
// GUI folder
const sphereFolder = mainGui.addFolder('Sphere ' + (index + 1));
sphereFolder.add(sphere.params, 'particleCount', 1000, 100000).step(1000)
.onChange(() => {
const {
newPositions,
newColors,
newVelocities,
newBasePositions,
newLifetimes,
newMaxLifetimes,
newBeatEffects
} = reinitializeParticlesForSphere(
sphere, sphere.params, sphere.geometry
);
sphere.positions = newPositions;
sphere.colors = newColors;
sphere.velocities = newVelocities;
sphere.basePositions = newBasePositions;
sphere.lifetimes = newLifetimes;
sphere.maxLifetimes = newMaxLifetimes;
sphere.beatEffects = newBeatEffects;
sphere.geometry.attributes.position.needsUpdate = true;
sphere.geometry.attributes.color.needsUpdate = true;
});
sphereFolder.add(sphere.params, 'particleSize', 0.001, 0.01).step(0.001)
.onChange(value => {
sphere.material.size = value;
});
if (index === 0) {
sphereFolder.add({ copyToOthers: () => {
for (let i = 1; i < spheres.length; i++) {
Object.assign(spheres[i].params, JSON.parse(JSON.stringify(sphere.params)));
const {
newPositions,
newColors,
newVelocities,
newBasePositions,
newLifetimes,
newMaxLifetimes,
newBeatEffects
} = reinitializeParticlesForSphere(
spheres[i], spheres[i].params, spheres[i].geometry
);
spheres[i].positions = newPositions;
spheres[i].colors = newColors;
spheres[i].velocities = newVelocities;
spheres[i].basePositions = newBasePositions;
spheres[i].lifetimes = newLifetimes;
spheres[i].maxLifetimes = newMaxLifetimes;
spheres[i].beatEffects = newBeatEffects;
spheres[i].geometry.attributes.position.needsUpdate = true;
spheres[i].geometry.attributes.color.needsUpdate = true;
spheres[i].particleSystem.visible = spheres[i].params.enabled;
const targetFolder = mainGui.__folders[`Sphere ${i + 1}`];
if (targetFolder) {
targetFolder.__controllers.forEach(controller => controller.updateDisplay());
}
}
mainGui.updateDisplay();
console.log('Parameters copied from Sphere 1 to Spheres 2-5.');
}}, 'copyToOthers').name('Copy to Spheres 2-5');
}
sphereFolder.add(sphere.params, 'particleLifetime', 1, 20).step(1);
sphereFolder.add(sphere.params, 'sphereRadius', 0.05, 3.0).step(0.05);
sphereFolder.add(sphere.params, 'innerSphereRadius', 0, 1).step(0.01)
.onChange(() => {
const {
newPositions,
newColors,
newVelocities,
newBasePositions,
newLifetimes,
newMaxLifetimes,
newBeatEffects
} = reinitializeParticlesForSphere(sphere, sphere.params, sphere.geometry);
sphere.positions = newPositions;
sphere.colors = newColors;
sphere.velocities = newVelocities;
sphere.basePositions = newBasePositions;
sphere.lifetimes = newLifetimes;
sphere.maxLifetimes = newMaxLifetimes;
sphere.beatEffects = newBeatEffects;
sphere.geometry.attributes.position.needsUpdate = true;
sphere.geometry.attributes.color.needsUpdate = true;
});
sphereFolder.add(sphere.params, 'rotationSpeedMin', 0, 0.02).step(0.001);
sphereFolder.add(sphere.params, 'rotationSpeedMax', 0, 0.1).step(0.001);
sphereFolder.add(sphere.params, 'rotationSmoothness', 0.01, 1).step(0.01);
sphereFolder.add(sphere.params, 'volumeChangeThreshold', 0.01, 0.2).step(0.01);
sphereFolder.add(sphereParams, 'minFrequency', 0, 22050).step(1).name('Min Frequency (Hz)')
.onChange(value => sphereParams.minFrequency = value);
sphereFolder.add(sphereParams, 'maxFrequency', 0, 22050).step(1).name('Max Frequency (Hz)')
.onChange(value => sphereParams.maxFrequency = value);
// GUI defaults
const minFreqController = sphereFolder.__controllers.find(c => c.property === 'minFrequency');
const maxFreqController = sphereFolder.__controllers.find(c => c.property === 'maxFrequency');
if (minFreqController) minFreqController.setValue(sphereParams.minFrequency);
if (maxFreqController) maxFreqController.setValue(sphereParams.maxFrequency);
sphereFolder.add(sphere.params, 'noiseScale', 0.1, 10.0).step(0.1);
sphereFolder.add(sphere.params, 'minNoiseScale', 0.0, 10.0).step(0.1).name('Min NoiseScale')
.onChange(() => {
if (sphere.params.minNoiseScale > sphere.params.maxNoiseScale) {
sphere.params.minNoiseScale = sphere.params.maxNoiseScale;
}
updateNoiseStep(sphere.params);
});
sphereFolder.add(sphere.params, 'maxNoiseScale', 0.0, 10.0).step(0.1).name('Max NoiseScale')
.onChange(() => {
if (sphere.params.maxNoiseScale < sphere.params.minNoiseScale) {
sphere.params.maxNoiseScale = sphere.params.minNoiseScale;
}
updateNoiseStep(sphere.params);
});
sphereFolder.add(sphere.params, 'noiseStep', 0.1, 5.0).step(0.1).name('Noise Step')
.onChange(() => {
updateNoiseStep(sphere.params);
});
function updateNoiseStep(params) {
const range = params.maxNoiseScale - params.minNoiseScale;
if (params.noiseStep > range) {
params.noiseStep = range / 2;
}
}
sphereFolder.add(sphere.params, 'noiseSpeed', 0, 1.0).step(0.01);
sphereFolder.add(sphere.params, 'peakSensitivity', 1.01, 2).step(0.01);
sphereFolder.add(sphere.peakDetection, 'historyLength', 10, 1200).step(1).name('History Length');
sphereFolder.add(sphere.peakDetection, 'minTimeBetweenPeaks', 50, 5000).step(10).name('Min Time Between Peaks');
sphereFolder.add(sphere.params, 'turbulenceStrength', 0, 0.03).step(0.0001);
sphereFolder.addColor(sphere.params, 'colorStart')
.onChange(() => updateColorsForSphere(sphere.params, sphere.geometry, sphere.colors));
sphereFolder.addColor(sphere.params, 'colorEnd')
.onChange(() => updateColorsForSphere(sphere.params, sphere.geometry, sphere.colors));
sphereFolder.add(sphereParams, 'minFrequencyBeat', 0, 22050).step(1).name('Min Freq Beat (Hz)')
.onChange(value => sphereParams.minFrequencyBeat = value);
sphereFolder.add(sphereParams, 'maxFrequencyBeat', 0, 22050).step(1).name('Max Freq Beat (Hz)')
.onChange(value => sphereParams.maxFrequencyBeat = value);
sphereFolder.add(sphere.params, 'beatThreshold', 50, 255).step(1);
sphereFolder.add(sphere.params, 'beatStrength', 0, 0.05).step(0.001);
sphereFolder.add(sphere.params, 'gainMultiplier', 1.0, 3.0).step(0.1);
sphereFolder.add(sphere.params, 'dynamicNoiseScale');
sphereFolder.add(sphere.params, 'enabled').onChange(value => {
sphere.particleSystem.visible = value;
});
sphereFolder.close();
return sphere;
}
for (let i = 0; i < 5; i++) {
const sphereVis = createSphereVisualization(i);
spheres.push(sphereVis);
}
// Saving defaults spheres
spheres.forEach(sphere => {
defaultParams.push(JSON.parse(JSON.stringify(sphere.params)));
});
// Init presets
updatePresetOptions();
// Only sphere 1 allowed
spheres.forEach((sphere, index) => {
if (index !== 0) {
sphere.params.enabled = false;
sphere.particleSystem.visible = false;
}
});
function getSmoothVolume(params, lastValidVolume, volumeChangeThreshold) {
if (!analyser) return { volume: 0, shouldUpdate: false };
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
analyser.getByteFrequencyData(dataArray);
let sum = 0;
for (let i = 0; i < bufferLength; i++) {
sum += dataArray[i];
}
const average = sum / bufferLength;
const normalizedVolume = average / 255;
let shouldUpdate = true;
if (lastValidVolume === 0) {
lastValidVolume = normalizedVolume;
} else {
const change = Math.abs(normalizedVolume - lastValidVolume);
if (change <= volumeChangeThreshold) {
lastValidVolume = normalizedVolume;
} else {
shouldUpdate = false;
}
}
return { volume: lastValidVolume, shouldUpdate };
}
let lastTime = 0;
function animate(currentTime) {
requestAnimationFrame(animate);
const deltaTime = lastTime ? (currentTime - lastTime) / 1000 : 0;
lastTime = currentTime;
// Beat wave update
beatManager.update(deltaTime);
// Sphere update
spheres.forEach(sphere => {
if (!sphere.params.enabled) return;
const audioData = getAudioData(sphere);
if (audioData.peakDetected) {
if (sphere.params.dynamicNoiseScale) {
sphere.params.noiseScale = generateNewNoiseScale(
sphere.params,
sphere.lastNoiseScale
);
sphere.lastNoiseScale = sphere.params.noiseScale;
}
}
const { params, geometry, positions, velocities, basePositions, lifetimes, maxLifetimes, beatEffects } = sphere;
// Beat detection sphere
const beatDetected = audioData.rangeEnergyBeat > params.beatThreshold;
// Beat wave sphere
if (beatDetected && !beatManager.isWaveActive && params.beatStrength > 0) {
beatManager.triggerWave(audioData.rangeEnergyBeat);
}
// Update particles
const pc = params.particleCount;
for (let i = 0; i < pc; i++) {
const i3 = i * 3;
let x = positions[i3];
let y = positions[i3 + 1];
let z = positions[i3 + 2];
let vx = velocities[i3];
let vy = velocities[i3 + 1];
let vz = velocities[i3 + 2];
let lt = lifetimes[i];
let be = beatEffects[i];
// Update lifetime
lt -= deltaTime;
// Noise calc
const ns = params.noiseScale;
const speed = params.noiseSpeed;
const timeFactor = currentTime * 0.001;
const noiseX = noise.noise3D(x * ns + timeFactor * speed, y * ns, z * ns);
const noiseY = noise.noise3D(x * ns, y * ns + timeFactor * speed, z * ns);
const noiseZ = noise.noise3D(x * ns, y * ns, z * ns + timeFactor * speed);
vx += noiseX * params.turbulenceStrength;
vy += noiseY * params.turbulenceStrength;
vz += noiseZ * params.turbulenceStrength;
// Beat effect
if (beatDetected) {
be = 1.0;
}
be *= 0.95;
if (be > 0.01) {
// směr z centra
const dist = Math.sqrt(x*x + y*y + z*z);
if (dist > 0) {
const dx = x / dist;
const dy = y / dist;
const dz = z / dist;
const beatForce = be * params.beatStrength;
vx += dx * beatForce;
vy += dy * beatForce;
vz += dz * beatForce;
}
}
// Update positions
x += vx;
y += vy;
z += vz;
// Slowing speed
vx *= 0.98;
vy *= 0.98;
vz *= 0.98;
// Radius control
const dist = Math.sqrt(x*x + y*y + z*z);
if (dist > params.sphereRadius) {
const overflow = dist - params.sphereRadius;
const pullback = overflow * 0.1;
if (dist > 0) {
const dx = x / dist;
const dy = y / dist;
const dz = z / dist;
x -= dx * pullback;
y -= dy * pullback;
z -= dz * pullback;
}
vx *= 0.9;
vy *= 0.9;
vz *= 0.9;
}
// Reset of dead particles
if (lt <= 0) {
const radius = THREE.MathUtils.lerp(0, params.sphereRadius, params.innerSphereRadius);
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const rr = Math.cbrt(Math.random()) * radius;
x = rr * Math.sin(phi) * Math.cos(theta);
y = rr * Math.sin(phi) * Math.sin(theta);
z = rr * Math.cos(phi);
vx = 0;
vy = 0;
vz = 0;
const newLt = Math.random() * params.particleLifetime;
lt = newLt;
maxLifetimes[i] = newLt;
be = 0;
basePositions[i3] = x;
basePositions[i3 + 1] = y;
basePositions[i3 + 2] = z;
}
positions[i3] = x;
positions[i3 + 1] = y;
positions[i3 + 2] = z;
velocities[i3] = vx;
velocities[i3 + 1] = vy;
velocities[i3 + 2] = vz;
lifetimes[i] = lt;
beatEffects[i] = be;
}
geometry.attributes.position.needsUpdate = true;
// Dyn rotation
const { volume: smoothVolume, shouldUpdate } = getSmoothVolume(
params,
sphere.lastValidVolume,
params.volumeChangeThreshold
);
if (shouldUpdate) {
const targetRotationSpeed = THREE.MathUtils.lerp(
params.rotationSpeedMin,
params.rotationSpeedMax,
smoothVolume
);
sphere.lastRotationSpeed = params.rotationSpeed +
(targetRotationSpeed - params.rotationSpeed) *
params.rotationSmoothness;
}
sphere.particleSystem.rotation.y += sphere.lastRotationSpeed;
// Saving volume
if (shouldUpdate) sphere.lastValidVolume = smoothVolume;
});
renderer.render(scene, camera);
updateTimeline();
}
window.addEventListener('resize', () => {
renderer.setSize(window.innerWidth, window.innerHeight);
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
});
let controlsVisible = true; // Stav viditelnosti
// Switching control elements
function toggleControlsVisibility() {
controlsVisible = !controlsVisible;
const allControls = document.querySelectorAll(
'.controls-container, .dg.main, #audioControls, #presetContainer, #songSelect, #playPause, input[type="range"], button, select, input[type="text"]'
);
allControls.forEach(control => {
control.style.display = controlsVisible ? 'block' : 'none';
});
}
// Event listener on click
document.addEventListener('click', (event) => {
const clickedElement = event.target;
if (!clickedElement.closest('.controls-container') &&
!clickedElement.closest('.dg.main') &&
!clickedElement.closest('#audioControls') &&
!clickedElement.closest('#presetContainer') &&
!clickedElement.closest('#songSelect') &&
!clickedElement.closest('#playPause') &&
!clickedElement.closest('input[type="range"]') &&
!clickedElement.closest('button') &&
!clickedElement.closest('select') &&
!clickedElement.closest('input[type="text"]')) {
toggleControlsVisibility();
}
});
document.querySelectorAll('.controls-container, .dg.main, #audioControls, #presetContainer').forEach(control => {
control.style.position = 'absolute';
});
console.log('Starting animation');
initAudio();
animate(0);