feat(visualizer): add multiple visualizers with mouse tracking and beat response

- Add BaseVisualizer class for shared visualizer logic
- Add Helix visualizer (DNA helix with rungs, beat compression/bounce)
- Add Tunnel visualizer (ring tunnel with depth parallax)
- Refactor SphereVisualizer to extend BaseVisualizer
- Add mouse tracking to all visualizers (canvas-relative, centered)
- Add visualizer switching via logo click (persisted to localStorage)
- Add beat-responsive bouncing and compression effects

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-29 21:11:38 +01:00
parent d46e54b592
commit e50c8a2503
9 changed files with 609 additions and 55 deletions

View File

@@ -0,0 +1,90 @@
/**
* Base Visualizer Class
* Abstract base for all audio visualizers
*/
import * as THREE from 'three';
export class BaseVisualizer {
constructor(scene) {
this.scene = scene;
this.mesh = null;
this.isVisible = false;
}
/**
* Initialize geometry, materials, and mesh
* Override in subclasses
*/
init() {
throw new Error('init() must be implemented by subclass');
}
/**
* Update visualizer with audio data
* @param {Object} bands - { low, mid, high } frequency bands (0-1)
* @param {number} time - Animation time
*/
update(bands, time) {
throw new Error('update() must be implemented by subclass');
}
/**
* Add visualizer to scene
*/
show() {
if (this.mesh && !this.isVisible) {
this.scene.add(this.mesh);
this.isVisible = true;
}
}
/**
* Remove visualizer from scene
*/
hide() {
if (this.mesh && this.isVisible) {
this.scene.remove(this.mesh);
this.isVisible = false;
}
}
/**
* Cleanup resources
*/
dispose() {
this.hide();
if (this.mesh) {
this.mesh.geometry?.dispose();
if (this.mesh.material) {
if (Array.isArray(this.mesh.material)) {
this.mesh.material.forEach(m => m.dispose());
} else {
this.mesh.material.dispose();
}
}
}
}
/**
* Helper: Create shader material with common settings
*/
createShaderMaterial(vertexShader, fragmentShader, uniforms = {}) {
return new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uTime: { value: 0 },
uLow: { value: 0 },
uMid: { value: 0 },
uHigh: { value: 0 },
...uniforms
},
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false
});
}
}
export default BaseVisualizer;

View File

@@ -0,0 +1,201 @@
/**
* DNA Helix Visualizer
* Immersive multi-layered double helix that fills the screen
*/
import * as THREE from 'three';
import { BaseVisualizer } from './base-visualizer.js';
const vertexShader = `
uniform float uTime;
uniform float uLow;
uniform float uMid;
uniform float uHigh;
uniform float uMouseX;
uniform float uMouseY;
attribute float aStrand; // 0 or 1 for which strand
attribute float aProgress; // 0-1 position along helix
attribute float aIsRung; // 1 if this is a connecting rung particle
attribute float aLayer; // Which helix layer (0, 1, 2 for nested helixes)
varying float vAlpha;
varying float vStrand;
varying float vLayer;
void main() {
vec3 pos = position;
// Layer-based radius - nested helixes
float baseRadius = 8.0 + aLayer * 12.0; // Inner: 8, Mid: 20, Outer: 32
float helixRadius = baseRadius + uLow * 6.0; // Bass expands all layers
float helixHeight = 100.0 * (1.0 - uLow * 0.3); // Compress with bass
float twists = 5.0 - aLayer * 1.0; // Inner twists more
// Rotation speed - layers rotate at different speeds
float layerSpeed = 1.0 - aLayer * 0.2;
float rotationSpeed = (0.6 + uMid * 2.0) * layerSpeed;
float timeOffset = uTime * rotationSpeed;
// Reverse rotation for alternate layers
if (mod(aLayer, 2.0) > 0.5) {
timeOffset = -timeOffset;
}
// Calculate helix position
float t = aProgress;
float angle = t * twists * 6.28318 + timeOffset;
// Offset for second strand
if (aStrand > 0.5 && aIsRung < 0.5) {
angle += 3.14159;
}
// Base helix position - along Z-axis (viewer inside helix)
float x = cos(angle) * helixRadius;
float y = sin(angle) * helixRadius;
float z = (t - 0.5) * helixHeight;
// Rungs connect the two strands
if (aIsRung > 0.5) {
float rungAngle = t * twists * 6.28318 + timeOffset;
float rungT = fract(aStrand * 10.0);
float angle1 = rungAngle;
float angle2 = rungAngle + 3.14159;
x = mix(cos(angle1), cos(angle2), rungT) * helixRadius;
y = mix(sin(angle1), sin(angle2), rungT) * helixRadius;
// Rungs pulse dramatically with beat
float pulse = 1.0 + uLow * 0.5;
x *= pulse;
y *= pulse;
}
// High frequencies add jitter/scatter - more on outer layers
float jitterAmount = uHigh * (3.0 + aLayer * 2.0);
x += sin(t * 40.0 + uTime * 8.0 + aLayer) * jitterAmount;
y += cos(t * 40.0 + uTime * 8.0) * jitterAmount * 0.5;
z += sin(t * 40.0 + uTime * 8.0 + 1.57 + aLayer) * jitterAmount;
// Mouse offset - helix follows mouse position subtly
x -= uMouseX * 1.0;
y -= uMouseY * 0.7;
// Bounce toward viewer with bass
z += uLow * 10.0;
pos = vec3(x, y, z);
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
// Point size - larger for outer layers
float size = 3.0 + aLayer * 1.0;
if (aIsRung > 0.5) {
size = 2.0 + uLow * 1.5;
}
size *= (1.0 + uLow * 0.3);
gl_PointSize = size * (300.0 / -mvPosition.z);
// Alpha - outer layers slightly more transparent
vAlpha = 0.8 - aLayer * 0.1 + uLow * 0.2;
if (aIsRung > 0.5) {
vAlpha *= 0.7;
}
vStrand = aStrand;
vLayer = aLayer;
gl_Position = projectionMatrix * mvPosition;
}
`;
const fragmentShader = `
varying float vAlpha;
varying float vStrand;
varying float vLayer;
void main() {
float dist = length(gl_PointCoord - vec2(0.5));
if (dist > 0.5) discard;
float alpha = 1.0 - smoothstep(0.1, 0.5, dist);
alpha *= vAlpha;
// Pure white
vec3 color = vec3(1.0);
gl_FragColor = vec4(color, alpha);
}
`;
export class HelixVisualizer extends BaseVisualizer {
static name = 'Helix';
constructor(scene) {
super(scene);
this.particlesPerStrand = 400;
this.rungsCount = 60;
this.layers = 1; // Single helix
this.init();
}
init() {
const rungsPerLayer = this.rungsCount;
const particlesPerRung = 15;
const totalParticles = this.layers * rungsPerLayer * particlesPerRung;
const positions = new Float32Array(totalParticles * 3);
const strands = new Float32Array(totalParticles);
const progress = new Float32Array(totalParticles);
const isRung = new Float32Array(totalParticles);
const layers = new Float32Array(totalParticles);
let idx = 0;
for (let layer = 0; layer < this.layers; layer++) {
// Rungs only (connecting particles between strands)
for (let r = 0; r < rungsPerLayer; r++) {
const rungProgress = r / rungsPerLayer;
for (let p = 0; p < particlesPerRung; p++) {
positions[idx * 3] = 0;
positions[idx * 3 + 1] = 0;
positions[idx * 3 + 2] = 0;
strands[idx] = p / (particlesPerRung - 1);
progress[idx] = rungProgress;
isRung[idx] = 1;
layers[idx] = layer;
idx++;
}
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('aStrand', new THREE.BufferAttribute(strands, 1));
geometry.setAttribute('aProgress', new THREE.BufferAttribute(progress, 1));
geometry.setAttribute('aIsRung', new THREE.BufferAttribute(isRung, 1));
geometry.setAttribute('aLayer', new THREE.BufferAttribute(layers, 1));
this.material = this.createShaderMaterial(vertexShader, fragmentShader, {
uMouseX: { value: 0 },
uMouseY: { value: 0 }
});
this.mesh = new THREE.Points(geometry, this.material);
}
update(bands, time, mouse = { x: 0, y: 0 }) {
if (!this.material) return;
this.material.uniforms.uTime.value = time;
this.material.uniforms.uLow.value = bands.low || 0;
this.material.uniforms.uMid.value = bands.mid || 0;
this.material.uniforms.uHigh.value = bands.high || 0;
this.material.uniforms.uMouseX.value = mouse.x;
this.material.uniforms.uMouseY.value = mouse.y;
}
}
export default HelixVisualizer;

View File

@@ -1,17 +1,19 @@
/**
* Particle System
* Particle System (Sphere) Visualizer
* Audio-reactive 3D particle cloud
*/
import * as THREE from 'three';
import { BaseVisualizer } from './base-visualizer.js';
// Vertex Shader
const vertexShader = `
uniform float uTime;
uniform float uLow;
uniform float uMid;
uniform float uHigh;
uniform float uSize;
uniform float uMouseX;
uniform float uMouseY;
attribute float aRandom;
attribute float aScale;
@@ -42,6 +44,10 @@ void main() {
vec3 jitter = normalize(pos) * uHigh * aRandom * 5.0;
pos += jitter;
// Mouse offset
pos.x -= uMouseX * 3.0;
pos.y -= uMouseY * 2.0;
// Calculate view position
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
@@ -57,7 +63,6 @@ void main() {
}
`;
// Fragment Shader
const fragmentShader = `
varying float vAlpha;
@@ -74,17 +79,16 @@ void main() {
}
`;
export class ParticleSystem {
constructor(count = 5000) {
export class SphereVisualizer extends BaseVisualizer {
static name = 'Sphere';
constructor(scene, count = 5000) {
super(scene);
this.count = count;
this.createGeometry();
this.createMaterial();
this.mesh = new THREE.Points(this.geometry, this.material);
this.init();
}
createGeometry() {
this.geometry = new THREE.BufferGeometry();
init() {
const positions = new Float32Array(this.count * 3);
const randoms = new Float32Array(this.count);
const scales = new Float32Array(this.count);
@@ -105,41 +109,37 @@ export class ParticleSystem {
scales[i] = 0.5 + Math.random() * 1.5;
}
this.geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
this.geometry.setAttribute('aRandom', new THREE.BufferAttribute(randoms, 1));
this.geometry.setAttribute('aScale', new THREE.BufferAttribute(scales, 1));
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('aRandom', new THREE.BufferAttribute(randoms, 1));
geometry.setAttribute('aScale', new THREE.BufferAttribute(scales, 1));
createMaterial() {
this.material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uTime: { value: 0 },
uLow: { value: 0 },
uMid: { value: 0 },
uHigh: { value: 0 },
uSize: { value: 2.0 }
},
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false
this.material = this.createShaderMaterial(vertexShader, fragmentShader, {
uSize: { value: 2.0 },
uMouseX: { value: 0 },
uMouseY: { value: 0 }
});
this.mesh = new THREE.Points(geometry, this.material);
}
update(bands, time) {
update(bands, time, mouse = { x: 0, y: 0 }) {
if (!this.material) return;
this.material.uniforms.uTime.value = time;
this.material.uniforms.uLow.value = bands.low || 0;
this.material.uniforms.uMid.value = bands.mid || 0;
this.material.uniforms.uHigh.value = bands.high || 0;
}
dispose() {
this.geometry?.dispose();
this.material?.dispose();
this.material.uniforms.uMouseX.value = mouse.x;
this.material.uniforms.uMouseY.value = mouse.y;
}
}
export default ParticleSystem;
// Legacy export for backwards compatibility
export class ParticleSystem extends SphereVisualizer {
constructor(count = 5000) {
super(null, count);
}
}
export default SphereVisualizer;

View File

@@ -1,10 +1,19 @@
/**
* WebGL Visualizer Scene
* Three.js particle system that reacts to audio frequency data
* Three.js scene manager with multiple selectable visualizers
*/
import * as THREE from 'three';
import { ParticleSystem } from './particles.js';
import { SphereVisualizer } from './particles.js';
import { TunnelVisualizer } from './tunnel.js';
import { HelixVisualizer } from './helix.js';
// Available visualizer classes
const VISUALIZERS = [
SphereVisualizer,
TunnelVisualizer,
HelixVisualizer
];
export class Visualizer {
constructor(canvas, audioManager) {
@@ -13,6 +22,14 @@ export class Visualizer {
this.running = false;
this.time = 0;
// Mouse tracking (normalized -1 to 1)
this.mouse = { x: 0, y: 0 };
this.mouseActive = false;
// Visualizer management
this.visualizers = [];
this.currentIndex = 0;
if (!canvas) {
console.warn('Visualizer: No canvas provided');
return;
@@ -44,17 +61,93 @@ export class Visualizer {
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
// Particles
this.particles = new ParticleSystem(5000);
this.scene.add(this.particles.mesh);
// Initialize all visualizers
this.visualizers = VISUALIZERS.map(VisualizerClass => {
return new VisualizerClass(this.scene);
});
// Load saved visualizer preference
const savedIndex = localStorage.getItem('pivoine-visualizer');
if (savedIndex !== null) {
this.currentIndex = parseInt(savedIndex, 10) % this.visualizers.length;
}
// Show the current visualizer
this.visualizers[this.currentIndex].show();
// Event listeners
window.addEventListener('resize', () => this.resize());
window.addEventListener('mousemove', (e) => this.onMouseMove(e));
// Start animation
this.start();
}
/**
* Get list of visualizer names
*/
getVisualizerNames() {
return VISUALIZERS.map(V => V.name);
}
/**
* Get current visualizer name
*/
getCurrentName() {
return VISUALIZERS[this.currentIndex].name;
}
/**
* Get current visualizer index
*/
getCurrentIndex() {
return this.currentIndex;
}
/**
* Set active visualizer by index
*/
setVisualizer(index) {
if (index < 0 || index >= this.visualizers.length) return;
if (index === this.currentIndex) return;
// Hide current
this.visualizers[this.currentIndex].hide();
// Show new
this.currentIndex = index;
this.visualizers[this.currentIndex].show();
// Save preference
localStorage.setItem('pivoine-visualizer', index.toString());
}
/**
* Cycle to next visualizer
*/
nextVisualizer() {
const nextIndex = (this.currentIndex + 1) % this.visualizers.length;
this.setVisualizer(nextIndex);
return this.getCurrentName();
}
/**
* Cycle to previous visualizer
*/
prevVisualizer() {
const prevIndex = (this.currentIndex - 1 + this.visualizers.length) % this.visualizers.length;
this.setVisualizer(prevIndex);
return this.getCurrentName();
}
onMouseMove(e) {
// Normalize to -1 to 1 relative to canvas
const rect = this.canvas.getBoundingClientRect();
this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
this.mouseActive = true;
}
resize() {
if (!this.camera || !this.renderer) return;
@@ -89,8 +182,12 @@ export class Visualizer {
bands = this.audioManager.getFrequencyBands();
}
// Update particles with audio data
this.particles.update(bands, this.time);
// Update current visualizer with audio data and mouse position
const currentViz = this.visualizers[this.currentIndex];
if (currentViz) {
const mouse = this.mouseActive ? this.mouse : { x: 0, y: 0 };
currentViz.update(bands, this.time, mouse);
}
// Subtle camera movement
this.camera.position.x = Math.sin(this.time * 0.1) * 3;
@@ -102,7 +199,7 @@ export class Visualizer {
destroy() {
this.running = false;
this.particles?.dispose();
this.visualizers.forEach(v => v.dispose());
this.renderer?.dispose();
}
}

View File

@@ -0,0 +1,146 @@
/**
* Waveform Tunnel Visualizer
* Infinite tunnel of pulsing rings
*/
import * as THREE from 'three';
import { BaseVisualizer } from './base-visualizer.js';
const vertexShader = `
uniform float uTime;
uniform float uLow;
uniform float uMid;
uniform float uHigh;
uniform float uMouseX;
uniform float uMouseY;
attribute float aRingIndex;
attribute float aAngle;
varying float vAlpha;
varying float vRingIndex;
void main() {
vec3 pos = position;
// Ring depth position (z movement for tunnel effect)
float depth = mod(pos.z + uTime * 12.0, 200.0) - 100.0;
pos.z = depth;
// Distance from camera affects ring behavior
float depthFactor = 1.0 - (depth + 100.0) / 200.0;
// Bass expands rings - stronger pulse
float bassExpand = 1.0 + uLow * 0.8 * depthFactor;
pos.x *= bassExpand;
pos.y *= bassExpand;
// Bounce toward viewer on bass
pos.z += uLow * 15.0;
// Mid creates wave distortion on ring
float wave = sin(aAngle * 4.0 + uTime * 3.0) * uMid * 2.0 * depthFactor;
float waveRadius = 1.0 + wave * 0.3;
pos.x *= waveRadius;
pos.y *= waveRadius;
// High adds subtle jitter
pos.x += sin(aAngle * 8.0 + uTime * 5.0) * uHigh * 0.5;
pos.y += cos(aAngle * 8.0 + uTime * 5.0) * uHigh * 0.5;
// Mouse offset - stronger for closer rings
pos.x -= uMouseX * 5.0 * depthFactor;
pos.y -= uMouseY * 4.0 * depthFactor;
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
// Point size based on depth - larger for more coverage
float size = 4.5 * (1.0 + uLow * 0.4);
gl_PointSize = size * (250.0 / -mvPosition.z);
// Alpha fades with depth
vAlpha = 0.8 * depthFactor + uHigh * 0.2;
vRingIndex = aRingIndex;
gl_Position = projectionMatrix * mvPosition;
}
`;
const fragmentShader = `
varying float vAlpha;
varying float vRingIndex;
void main() {
float dist = length(gl_PointCoord - vec2(0.5));
if (dist > 0.5) discard;
float alpha = 1.0 - smoothstep(0.1, 0.5, dist);
alpha *= vAlpha;
// Slight color variation based on ring
vec3 color = vec3(1.0, 1.0, 1.0);
gl_FragColor = vec4(color, alpha);
}
`;
export class TunnelVisualizer extends BaseVisualizer {
static name = 'Tunnel';
constructor(scene) {
super(scene);
this.ringCount = 100;
this.pointsPerRing = 128;
this.init();
}
init() {
const totalPoints = this.ringCount * this.pointsPerRing;
const positions = new Float32Array(totalPoints * 3);
const ringIndices = new Float32Array(totalPoints);
const angles = new Float32Array(totalPoints);
let idx = 0;
for (let ring = 0; ring < this.ringCount; ring++) {
const z = (ring / this.ringCount) * 200 - 100; // -100 to 100
const radius = 28 + Math.sin(ring * 0.25) * 5; // Larger varying radius
for (let p = 0; p < this.pointsPerRing; p++) {
const angle = (p / this.pointsPerRing) * Math.PI * 2;
positions[idx * 3] = Math.cos(angle) * radius;
positions[idx * 3 + 1] = Math.sin(angle) * radius;
positions[idx * 3 + 2] = z;
ringIndices[idx] = ring / this.ringCount;
angles[idx] = angle;
idx++;
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('aRingIndex', new THREE.BufferAttribute(ringIndices, 1));
geometry.setAttribute('aAngle', new THREE.BufferAttribute(angles, 1));
this.material = this.createShaderMaterial(vertexShader, fragmentShader, {
uMouseX: { value: 0 },
uMouseY: { value: 0 }
});
this.mesh = new THREE.Points(geometry, this.material);
}
update(bands, time, mouse = { x: 0, y: 0 }) {
if (!this.material) return;
this.material.uniforms.uTime.value = time;
this.material.uniforms.uLow.value = bands.low || 0;
this.material.uniforms.uMid.value = bands.mid || 0;
this.material.uniforms.uHigh.value = bands.high || 0;
this.material.uniforms.uMouseX.value = mouse.x;
this.material.uniforms.uMouseY.value = mouse.y;
}
}
export default TunnelVisualizer;

View File

@@ -1,4 +1,4 @@
description = "Valknar's audio"
description = "Valknar's Pivoine.Art"
author = "Valknar"
email = "valknar@pivoine.art"

View File

@@ -1,4 +1,4 @@
---
title: "Valknar's"
description: "Valknar's audio"
description: "Valknar's Pivoine.Art"
---

View File

@@ -64,6 +64,18 @@
// Player UI component
Alpine.data('playerUI', () => ({
visualizerName: 'Sphere',
init() {
// Get initial visualizer name
this.$nextTick(() => {
const viz = window.__pivoine?.visualizer;
if (viz) {
this.visualizerName = viz.getCurrentName() || 'Sphere';
}
});
},
togglePlay() {
window.__pivoine?.audioManager?.toggle();
},
@@ -85,6 +97,12 @@
this.setVolume(this._previousVolume || 0.8);
}
},
cycleVisualizer() {
const viz = window.__pivoine?.visualizer;
if (viz) {
this.visualizerName = viz.nextVisualizer();
}
},
formatTime(seconds) {
if (!seconds || isNaN(seconds)) return '0:00';
const mins = Math.floor(seconds / 60);

View File

@@ -1,23 +1,25 @@
<header class="fixed top-0 left-0 right-0 z-sticky bg-surface-0/80 backdrop-blur-md border-b border-border">
<nav class="container-wide flex items-center justify-between h-16">
{{/* Logo */}}
<a
href="{{ "/" | relURL }}"
class="flex items-center gap-3 group"
aria-label="{{ .Site.Title }} - Home"
>
<div class="flex items-center gap-3 group">
<canvas
id="logo-canvas"
hx-preserve="true"
class="w-8 h-8"
class="w-8 h-8 cursor-pointer"
width="32"
height="32"
aria-hidden="true"
@click="window.__pivoine?.visualizer?.nextVisualizer()"
title="Click to change visualizer"
></canvas>
<span class="text-lg font-medium tracking-tight group-hover:text-accent transition-colors">
<a
href="{{ "/" | relURL }}"
class="text-lg font-medium tracking-tight group-hover:text-accent transition-colors"
aria-label="{{ .Site.Title }} - Home"
>
VALKNAR'S
</span>
</a>
</a>
</div>
{{/* Navigation */}}
<ul