Initial commit

This commit is contained in:
AlacrisDevs
2026-02-16 19:57:42 +02:00
commit 53b0457857
11 changed files with 4204 additions and 0 deletions

31
.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# Dependencies
node_modules/
# Build output
dist/
# Vite
.vite/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS files
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Environment files
.env
.env.local
.env.*.local

BIN
erki.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 KiB

31
index.html Normal file
View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Audio Visualizer</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #0a0a0a;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1302
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "audio-visualizer",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.1.0",
"svelte": "^4.2.12",
"vite": "^5.2.0"
}
}

591
src/App.svelte Normal file
View File

@@ -0,0 +1,591 @@
<script>
import { onMount, onDestroy } from "svelte";
import AudioVisualizer from "./lib/AudioVisualizer.svelte";
import Controls from "./lib/Controls.svelte";
const STORAGE_KEY = "ncs-visualizer-settings";
const defaults = {
selectedPreset: "cyanPink",
logoUrl: "",
title: "Audio Visualizer",
subtitle: "Now Playing",
bgImage: "",
bgColor: "#0a0a0a",
customPrimary: "#00d4ff",
customSecondary: "#ff006e",
useCustomColors: false,
sidebarOpen: true,
vignette: true,
audioSource: "mic",
audioUrl: "",
vizMode: "circular",
barCount: 48,
sensitivity: 1.0,
smoothing: 0.82,
bassEmphasis: 0.25,
particlesEnabled: true,
particleDensity: 1.0,
shakeAmount: 1.0,
zoomIntensity: 1.0,
glowIntensity: 0.6,
logoSpin: false,
logoSpinSpeed: 5,
titlePosition: "top",
};
function loadSettings() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? { ...defaults, ...JSON.parse(raw) } : { ...defaults };
} catch {
return { ...defaults };
}
}
const saved = loadSettings();
let selectedPreset = saved.selectedPreset;
let logoUrl = saved.logoUrl;
let title = saved.title;
let subtitle = saved.subtitle;
let bgImage = saved.bgImage;
let bgColor = saved.bgColor;
let customPrimary = saved.customPrimary;
let customSecondary = saved.customSecondary;
let useCustomColors = saved.useCustomColors;
let isListening = false;
let sidebarOpen = saved.sidebarOpen;
let vignette = saved.vignette;
let audioSource = saved.audioSource;
let audioUrl = saved.audioUrl;
let vizMode = saved.vizMode;
let barCount = saved.barCount;
let sensitivity = saved.sensitivity;
let smoothing = saved.smoothing;
let bassEmphasis = saved.bassEmphasis;
let particlesEnabled = saved.particlesEnabled;
let particleDensity = saved.particleDensity;
let shakeAmount = saved.shakeAmount;
let zoomIntensity = saved.zoomIntensity;
let glowIntensity = saved.glowIntensity;
let logoSpin = saved.logoSpin;
let logoSpinSpeed = saved.logoSpinSpeed;
let titlePosition = saved.titlePosition;
let visualizerComponent;
let toggleHidden = false;
let hideTimer;
function startHideTimer() {
clearTimeout(hideTimer);
if (!sidebarOpen) {
hideTimer = setTimeout(() => {
toggleHidden = true;
}, 2000);
}
}
function handleToggleClick() {
sidebarOpen = !sidebarOpen;
toggleHidden = false;
clearTimeout(hideTimer);
if (!sidebarOpen) startHideTimer();
}
$: if (!sidebarOpen) {
startHideTimer();
} else {
toggleHidden = false;
clearTimeout(hideTimer);
}
$: {
const settings = {
selectedPreset,
logoUrl,
title,
subtitle,
bgImage,
bgColor,
customPrimary,
customSecondary,
useCustomColors,
sidebarOpen,
vignette,
audioSource,
audioUrl,
vizMode,
barCount,
sensitivity,
smoothing,
bassEmphasis,
particlesEnabled,
particleDensity,
shakeAmount,
zoomIntensity,
glowIntensity,
logoSpin,
logoSpinSpeed,
titlePosition,
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
} catch {}
}
function toggleListening() {
if (isListening) {
visualizerComponent.stopListening();
} else {
visualizerComponent.startListening();
}
}
function handleScreenshot() {
const dataUrl = visualizerComponent.takeScreenshot();
if (!dataUrl) return;
const link = document.createElement("a");
link.download = "visualizer-screenshot.png";
link.href = dataUrl;
link.click();
}
function handleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen().catch(() => {});
}
}
function handleReset() {
selectedPreset = defaults.selectedPreset;
logoUrl = defaults.logoUrl;
title = defaults.title;
subtitle = defaults.subtitle;
bgImage = defaults.bgImage;
bgColor = defaults.bgColor;
customPrimary = defaults.customPrimary;
customSecondary = defaults.customSecondary;
useCustomColors = defaults.useCustomColors;
vignette = defaults.vignette;
audioSource = defaults.audioSource;
audioUrl = defaults.audioUrl;
vizMode = defaults.vizMode;
barCount = defaults.barCount;
sensitivity = defaults.sensitivity;
smoothing = defaults.smoothing;
bassEmphasis = defaults.bassEmphasis;
particlesEnabled = defaults.particlesEnabled;
particleDensity = defaults.particleDensity;
shakeAmount = defaults.shakeAmount;
zoomIntensity = defaults.zoomIntensity;
glowIntensity = defaults.glowIntensity;
logoSpin = defaults.logoSpin;
logoSpinSpeed = defaults.logoSpinSpeed;
titlePosition = defaults.titlePosition;
}
let fileCurrentTime = 0;
let fileDuration = 0;
let filePlaying = false;
let fileName = "";
function handleAudioFile(e) {
const file = e.detail;
if (file && visualizerComponent) {
if (isListening) {
visualizerComponent.stopListening();
}
audioSource = "file";
visualizerComponent.setAudioFile(file);
}
}
function handleFilePause() {
if (visualizerComponent) visualizerComponent.pauseAudio();
}
function handleFileResume() {
if (visualizerComponent) visualizerComponent.resumeAudio();
}
function handleFileSeek(e) {
if (visualizerComponent) visualizerComponent.seekAudio(e.detail);
}
function handleKeydown(e) {
// Ignore if user is typing in an input
if (
e.target.tagName === "INPUT" ||
e.target.tagName === "TEXTAREA" ||
e.target.isContentEditable
)
return;
switch (e.code) {
case "Space":
e.preventDefault();
toggleListening();
break;
case "KeyF":
e.preventDefault();
handleFullscreen();
break;
case "KeyH":
e.preventDefault();
handleToggleClick();
break;
}
}
onMount(() => {
window.addEventListener("keydown", handleKeydown);
});
onDestroy(() => {
window.removeEventListener("keydown", handleKeydown);
});
const colorPresets = {
cyanPink: {
name: "Cyan/Pink",
primary: "#00d4ff",
secondary: "#ff006e",
},
purpleOrange: {
name: "Purple/Orange",
primary: "#8b5cf6",
secondary: "#f97316",
},
greenYellow: {
name: "Green/Yellow",
primary: "#22c55e",
secondary: "#eab308",
},
pinkBlue: {
name: "Pink/Blue",
primary: "#ec4899",
secondary: "#3b82f6",
},
redOrange: {
name: "Red/Orange",
primary: "#ef4444",
secondary: "#f97316",
},
white: {
name: "White",
primary: "#ffffff",
secondary: "#888888",
},
};
$: currentColors = useCustomColors
? { primary: customPrimary, secondary: customSecondary }
: colorPresets[selectedPreset];
</script>
<main style="--bg-color: {bgColor}">
{#if bgImage}
<div class="bg-image" style="background-image: url({bgImage})"></div>
{/if}
{#if vignette}
<div class="vignette"></div>
{/if}
<div class="stage">
<AudioVisualizer
bind:this={visualizerComponent}
colors={currentColors}
{logoUrl}
{audioSource}
{vizMode}
{barCount}
{sensitivity}
{smoothing}
{bassEmphasis}
{particlesEnabled}
{particleDensity}
{shakeAmount}
{zoomIntensity}
{glowIntensity}
{logoSpin}
{logoSpinSpeed}
bind:isListening
bind:fileCurrentTime
bind:fileDuration
bind:filePlaying
bind:fileName
/>
</div>
<div class="titles title-{titlePosition}">
<h1 style="color: {currentColors.primary}">{title}</h1>
<h2 style="color: {currentColors.secondary}">{subtitle}</h2>
</div>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="toggle-zone"
class:hidden={toggleHidden}
on:mouseenter={() => {
toggleHidden = false;
}}
>
<button
class="sidebar-toggle"
class:open={sidebarOpen}
on:click={handleToggleClick}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
{#if sidebarOpen}
<path d="M15 18l-6-6 6-6" />
{:else}
<path d="M9 18l6-6-6-6" />
{/if}
</svg>
</button>
</div>
<aside class="sidebar" class:open={sidebarOpen}>
<Controls
bind:selectedPreset
bind:logoUrl
bind:title
bind:subtitle
bind:bgImage
bind:bgColor
bind:vignette
bind:audioSource
bind:audioUrl
bind:customPrimary
bind:customSecondary
bind:useCustomColors
bind:vizMode
bind:barCount
bind:sensitivity
bind:smoothing
bind:bassEmphasis
bind:particlesEnabled
bind:particleDensity
bind:shakeAmount
bind:zoomIntensity
bind:glowIntensity
bind:logoSpin
bind:logoSpinSpeed
bind:titlePosition
{isListening}
{colorPresets}
onToggle={toggleListening}
on:screenshot={handleScreenshot}
on:fullscreen={handleFullscreen}
on:reset={handleReset}
on:audiofile={handleAudioFile}
on:filepause={handleFilePause}
on:fileresume={handleFileResume}
on:fileseek={handleFileSeek}
{fileCurrentTime}
{fileDuration}
{filePlaying}
{fileName}
/>
</aside>
</main>
<style>
main {
position: relative;
display: flex;
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--bg-color);
}
.bg-image {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
z-index: 0;
}
.vignette {
position: absolute;
inset: 0;
background: radial-gradient(
ellipse at center,
rgba(0, 0, 0, 0.5) 0%,
rgba(0, 0, 0, 1) 100%
);
z-index: 0;
}
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.titles {
position: absolute;
z-index: 10;
pointer-events: none;
text-align: center;
padding: 1.5rem 2.5rem;
background: radial-gradient(
ellipse at center,
rgba(0, 0, 0, 0.7) 0%,
rgba(0, 0, 0, 0.4) 50%,
transparent 80%
);
text-shadow:
0 2px 8px rgba(0, 0, 0, 1),
0 0 30px rgba(0, 0, 0, 0.9);
}
.title-top {
top: 0;
left: 50%;
transform: translateX(-50%);
}
.title-bottom {
bottom: 0;
left: 50%;
transform: translateX(-50%);
}
.title-center {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.title-top-left {
top: 0;
left: 0;
text-align: left;
}
.title-top-right {
top: 0;
right: 0;
text-align: right;
}
.title-bottom-left {
bottom: 0;
left: 0;
text-align: left;
}
.title-bottom-right {
bottom: 0;
right: 0;
text-align: right;
}
.title-left {
top: 50%;
left: 0;
transform: translateY(-50%);
text-align: left;
}
.title-right {
top: 50%;
right: 0;
transform: translateY(-50%);
text-align: right;
}
h1 {
font-size: clamp(1.4rem, 3.5vw, 3rem);
font-weight: 300;
letter-spacing: 0.2em;
text-transform: uppercase;
margin: 0;
}
h2 {
font-size: clamp(1rem, 2vw, 1.8rem);
font-weight: 300;
letter-spacing: 0.15em;
margin: 0.3rem 0 0 0;
opacity: 0.7;
}
.toggle-zone {
position: fixed;
top: 0;
right: 0;
width: 50px;
height: 100vh;
z-index: 30;
opacity: 1;
transition: opacity 0.5s ease;
}
.toggle-zone.hidden {
opacity: 0;
}
.toggle-zone.hidden:hover {
opacity: 1;
}
.sidebar-toggle {
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-right: none;
border-radius: 8px 0 0 8px;
color: #fff;
padding: 0.75rem 0.4rem;
cursor: pointer;
transition: right 0.3s ease;
}
.sidebar-toggle.open {
right: 320px;
}
.sidebar-toggle:hover {
background: rgba(255, 255, 255, 0.15);
}
.sidebar {
position: fixed;
top: 0;
right: -320px;
width: 320px;
height: 100vh;
background: rgba(10, 10, 15, 0.92);
backdrop-filter: blur(12px);
border-left: 1px solid rgba(255, 255, 255, 0.1);
z-index: 20;
overflow-y: auto;
transition: right 0.3s ease;
padding: 1.5rem;
box-sizing: border-box;
}
.sidebar.open {
right: 0;
}
</style>

View File

@@ -0,0 +1,872 @@
<script>
import { onMount, onDestroy } from "svelte";
export let colors = { primary: "#00d4ff", secondary: "#ff006e" };
export let logoUrl = "";
export let isListening = false;
export let audioSource = "mic"; // "mic", "tab", "link", "file"
// Visualization mode: "circular", "bars", "wave", "blob"
export let vizMode = "circular";
export let barCount = 48;
export let sensitivity = 1.0;
export let smoothing = 0.82;
export let bassEmphasis = 0.25;
export let particlesEnabled = true;
export let particleDensity = 1.0;
export let shakeAmount = 1.0;
export let zoomIntensity = 1.0;
export let glowIntensity = 0.6;
export let logoSpin = false;
export let logoSpinSpeed = 5;
const DEFAULT_SIZE = 250;
const LINE_LIFT = 8;
const FREQ_RANGE = 0.55;
const INV_255 = 1 / 255;
const SMOOTH_FACTOR = 0.45;
const SCALE_SMOOTH = 0.15;
const MIN_SCALE = 1.0;
const MAX_SCALE = 1.35;
const MAX_PARTICLES = 600;
const CANVAS_PAD = 2.5;
let canvas;
let ctx;
let animationId;
let audioContext;
let analyser;
let currentStream = null;
let audioElement = null;
let audioSourceNode = null;
let dataArray = new Uint8Array(128);
let bufferLength = 128;
let size = DEFAULT_SIZE;
let centerX = DEFAULT_SIZE / 2;
let centerY = DEFAULT_SIZE / 2;
let baseRadius = DEFAULT_SIZE * 0.25;
let cosValues = new Float32Array(barCount);
let sinValues = new Float32Array(barCount);
let smoothedValues = new Float32Array(Math.ceil(barCount / 2));
let smoothedScale = MIN_SCALE;
let smoothedLoudness = 0;
let visualizerWrapper;
let blobPhase = 0;
// Particle system
let particles = [];
$: {
const totalBars = barCount;
const halfBars = Math.ceil(totalBars / 2);
cosValues = new Float32Array(totalBars);
sinValues = new Float32Array(totalBars);
smoothedValues = new Float32Array(halfBars);
precalculateAngles();
}
function spawnParticles(loudness) {
if (!particlesEnabled) return;
const count = Math.floor(loudness * 8 * particleDensity);
const maxP = Math.floor(MAX_PARTICLES * particleDensity);
for (let j = 0; j < count; j++) {
if (particles.length >= maxP) break;
let p;
switch (vizMode) {
case "bars": {
// Sparks rise from random bar tops
const halfBars = Math.ceil(barCount / 2);
const cw = canvas ? canvas.width : window.innerWidth;
const ch = canvas ? canvas.height : window.innerHeight;
const totalWidth = cw;
const barGap = 3;
const barWidth = Math.max(
2,
(totalWidth - (halfBars - 1) * barGap) / halfBars,
);
const bottomY = ch;
const idx = Math.floor(Math.random() * halfBars);
const val = (smoothedValues[idx] || 0) * INV_255;
const h = Math.max(2, val * ch * 0.7);
const x = idx * (barWidth + barGap) + barWidth * Math.random();
p = {
x,
y: bottomY - h,
vx: (Math.random() - 0.5) * 2,
vy: -(1.5 + Math.random() * 4 * loudness),
life: 1.0,
decay: 0.008 + Math.random() * 0.02,
radius: 2 + Math.random() * 5,
};
break;
}
case "wave": {
// Particles float upward from the wave line
const halfBars = Math.ceil(barCount / 2);
const cw = canvas ? canvas.width : window.innerWidth;
const ch = canvas ? canvas.height : window.innerHeight;
const totalWidth = cw;
const maxAmp = ch * 0.3;
const midY = ch * 0.5;
const t = Math.random();
const idx = Math.floor(t * (halfBars - 1));
const val = (smoothedValues[idx] || 0) * INV_255;
const x = t * totalWidth;
const y = midY - val * maxAmp;
p = {
x,
y,
vx: (Math.random() - 0.5) * 1.5,
vy: -(0.8 + Math.random() * 3 * loudness),
life: 1.0,
decay: 0.006 + Math.random() * 0.012,
radius: 2 + Math.random() * 4,
};
break;
}
case "blob": {
// Particles swirl around the blob surface
const angle = Math.random() * Math.PI * 2;
const halfBars = Math.ceil(barCount / 2);
const freqIdx =
Math.floor(((((angle / (Math.PI * 2)) % 1) + 1) % 1) * halfBars) %
halfBars;
const val = (smoothedValues[freqIdx] || 0) * INV_255;
const maxDeform = size * 0.2;
const r = baseRadius + val * maxDeform + Math.random() * 10;
const tangent = angle + Math.PI / 2;
const speed = 1 + Math.random() * 3 * loudness;
p = {
x: centerX + Math.cos(angle) * r,
y: centerY + Math.sin(angle) * r,
vx: Math.cos(tangent) * speed + Math.cos(angle) * speed * 0.3,
vy: Math.sin(tangent) * speed + Math.sin(angle) * speed * 0.3,
life: 1.0,
decay: 0.006 + Math.random() * 0.015,
radius: 2 + Math.random() * 6,
};
break;
}
case "circular":
default: {
// Radiate outward from circle
const angle = Math.random() * Math.PI * 2;
const spawnR = baseRadius * 0.7 + Math.random() * baseRadius * 0.3;
const speed = 1.5 + Math.random() * 5 * loudness;
p = {
x: centerX + Math.cos(angle) * spawnR,
y: centerY + Math.sin(angle) * spawnR,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 1.0,
decay: 0.005 + Math.random() * 0.015,
radius: 3 + Math.random() * 7,
};
break;
}
}
particles.push(p);
}
}
function updateAndDrawParticles() {
if (!particlesEnabled) return;
const useSecondary = vizMode === "blob" || vizMode === "wave";
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx;
p.y += p.vy;
// Gravity for bars mode (sparks fall slightly)
if (vizMode === "bars") p.vy += 0.04;
// Slight drag for blob mode (swirl slows)
if (vizMode === "blob") {
p.vx *= 0.995;
p.vy *= 0.995;
}
p.life -= p.decay;
if (p.life <= 0) {
particles[i] = particles[particles.length - 1];
particles.pop();
continue;
}
const alpha = Math.floor(p.life * 200)
.toString(16)
.padStart(2, "0");
const color =
useSecondary && Math.random() > 0.5 ? colors.secondary : colors.primary;
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius * p.life, 0, Math.PI * 2);
ctx.fillStyle = color + alpha;
ctx.fill();
}
}
function precalculateAngles() {
const totalBars = barCount;
const halfBars = Math.ceil(totalBars / 2);
// Mirrored halves: right side goes bottom→top, left side mirrors
const angleStep = Math.PI / halfBars;
const halfStep = angleStep / 2;
for (let i = 0; i < halfBars; i++) {
// Right half: top → bottom
const rightAngle = -Math.PI / 2 + halfStep + i * angleStep;
cosValues[i] = Math.cos(rightAngle);
sinValues[i] = Math.sin(rightAngle);
// Left half: top → bottom (mirrored)
if (halfBars + i < totalBars) {
const leftAngle = -Math.PI / 2 - halfStep - i * angleStep;
cosValues[halfBars + i] = Math.cos(leftAngle);
sinValues[halfBars + i] = Math.sin(leftAngle);
}
}
}
async function initAudio() {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
// Browsers may suspend AudioContext until a user gesture resumes it
if (audioContext.state === "suspended") {
await audioContext.resume();
}
analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = smoothing;
bufferLength = analyser.frequencyBinCount;
dataArray = new Uint8Array(bufferLength);
let stream;
if (audioSource === "file" && audioElement) {
// Direct Web Audio pipeline — best quality, no mic needed
audioSourceNode = audioContext.createMediaElementSource(audioElement);
audioSourceNode.connect(analyser);
analyser.connect(audioContext.destination);
audioElement.play();
isListening = true;
return;
} else if (audioSource === "tab") {
// Tab audio capture via getDisplayMedia (Chrome/Edge only)
stream = await navigator.mediaDevices.getDisplayMedia({
video: { width: 1, height: 1, frameRate: 1 },
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
systemAudio: "include",
},
});
// Verify audio tracks exist (user must check "Share tab audio")
const audioTracks = stream.getAudioTracks();
if (audioTracks.length === 0) {
stream.getTracks().forEach((t) => t.stop());
throw new Error(
'No audio captured. Make sure to check "Share tab audio" in the sharing dialog.',
);
}
// Stop the video track immediately — we only need audio
stream.getVideoTracks().forEach((t) => t.stop());
// Handle stream ending (user clicks "Stop sharing")
stream.addEventListener("inactive", () => stopListening());
audioTracks.forEach((track) =>
track.addEventListener("ended", () => stopListening()),
);
} else {
// "mic" and "link" both use microphone capture
// For "link", the user plays music in an embedded/external player
// and the mic picks up the audio from speakers
stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
});
}
currentStream = stream;
const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser);
isListening = true;
}
$: if (analyser) {
analyser.smoothingTimeConstant = smoothing;
}
function getProcessedData() {
const totalBars = barCount;
const halfBars = Math.ceil(totalBars / 2);
const usableBins = (bufferLength * FREQ_RANGE) | 0;
let sum = 0;
for (let i = 0; i < halfBars; i++) {
const t = i / halfBars;
const binIdx = Math.min(
usableBins - 1,
(Math.pow(t, 1.15) * usableBins) | 0,
);
const lo = Math.max(0, binIdx - 1);
const hi = Math.min(usableBins - 1, binIdx + 1);
const raw =
((dataArray[lo] || 0) +
(dataArray[binIdx] || 0) +
(dataArray[hi] || 0)) /
3;
// Bass emphasis controlled by prop
const bassBoost = 1 + (1 - t) * bassEmphasis;
// Sensitivity multiplier
const normalized = Math.min(1, (raw * bassBoost * sensitivity) / 255);
const spiked = Math.pow(normalized, 16.0) * 255;
smoothedValues[i] += (spiked - smoothedValues[i]) * SMOOTH_FACTOR;
}
// Spatial smoothing: blend adjacent bars for a more unified shape
for (let pass = 0; pass < 2; pass++) {
for (let i = 1; i < halfBars - 1; i++) {
smoothedValues[i] =
smoothedValues[i - 1] * 0.1 +
smoothedValues[i] * 0.8 +
smoothedValues[i + 1] * 0.1;
}
}
for (let i = 0; i < halfBars; i++) sum += smoothedValues[i];
const avgLoudness = (sum / halfBars) * INV_255;
smoothedLoudness += (avgLoudness - smoothedLoudness) * 0.2;
return { halfBars, totalBars, sum };
}
// Clear particles when viz mode changes to avoid stale coordinates
let prevVizMode = vizMode;
$: if (vizMode !== prevVizMode) {
particles = [];
prevVizMode = vizMode;
}
function applyWrapperEffects() {
const shakeIntensity = 12 * shakeAmount;
const zoom = isFinite(zoomIntensity) ? zoomIntensity : 1.0;
const targetScale =
MIN_SCALE + smoothedLoudness * (MAX_SCALE - MIN_SCALE) * zoom;
smoothedScale += (targetScale - smoothedScale) * SCALE_SMOOTH;
const shakeX = (Math.random() - 0.5) * shakeIntensity * smoothedLoudness;
const shakeY = (Math.random() - 0.5) * shakeIntensity * smoothedLoudness;
const bloom = 1 + smoothedLoudness * glowIntensity;
if (visualizerWrapper) {
const isFullViewport = vizMode === "bars" || vizMode === "wave";
if (isFullViewport) {
visualizerWrapper.style.transform = "none";
} else {
visualizerWrapper.style.transform = `scale(${smoothedScale}) translate(${shakeX}px, ${shakeY}px)`;
}
visualizerWrapper.style.filter = `brightness(${bloom})`;
}
}
function drawCircular(halfBars, totalBars) {
const maxBarHeight = size * 0.25;
const barHeights = new Float32Array(totalBars);
for (let i = 0; i < totalBars; i++) {
const dataIdx = i < halfBars ? i : i - halfBars;
barHeights[i] =
baseRadius + 5 + smoothedValues[dataIdx] * maxBarHeight * INV_255;
}
// Build outer points for mirrored halves
const outerPoints = [];
for (let i = 0; i < halfBars; i++) {
const r = barHeights[i] + LINE_LIFT;
outerPoints.push({
x: centerX + cosValues[i] * r,
y: centerY + sinValues[i] * r,
});
}
for (let i = totalBars - 1; i >= halfBars; i--) {
const r = barHeights[i] + LINE_LIFT;
outerPoints.push({
x: centerX + cosValues[i] * r,
y: centerY + sinValues[i] * r,
});
}
// Draw filled area from circle edge to outer line
const len = outerPoints.length;
ctx.beginPath();
ctx.moveTo(outerPoints[0].x, outerPoints[0].y);
for (let i = 0; i < len; i++) {
const curr = outerPoints[i];
const next = outerPoints[(i + 1) % len];
const mx = (curr.x + next.x) / 2;
const my = (curr.y + next.y) / 2;
ctx.quadraticCurveTo(curr.x, curr.y, mx, my);
}
ctx.closePath();
// Cut out the inner circle
ctx.moveTo(centerX + baseRadius, centerY);
ctx.arc(centerX, centerY, baseRadius, 0, Math.PI * 2, true);
// Gradient fill
const grad = ctx.createRadialGradient(
centerX,
centerY,
baseRadius,
centerX,
centerY,
size * 0.48,
);
grad.addColorStop(0, colors.secondary);
grad.addColorStop(1, colors.primary);
ctx.fillStyle = grad;
ctx.fill("evenodd");
}
function drawBars(halfBars) {
const totalBarsToShow = halfBars;
const barGap = 3;
const totalWidth = canvas.width;
const barWidth = Math.max(
2,
(totalWidth - (totalBarsToShow - 1) * barGap) / totalBarsToShow,
);
const maxBarHeight = canvas.height * 0.7;
const startX = 0;
const bottomY = canvas.height;
const grad = ctx.createLinearGradient(
0,
bottomY - maxBarHeight,
0,
bottomY,
);
grad.addColorStop(0, colors.primary);
grad.addColorStop(1, colors.secondary);
ctx.fillStyle = grad;
for (let i = 0; i < totalBarsToShow; i++) {
const val = smoothedValues[i] * INV_255;
const h = Math.max(2, val * maxBarHeight);
const x = startX + i * (barWidth + barGap);
const radius = Math.min(barWidth / 2, 4);
ctx.beginPath();
ctx.moveTo(x + radius, bottomY - h);
ctx.lineTo(x + barWidth - radius, bottomY - h);
ctx.quadraticCurveTo(
x + barWidth,
bottomY - h,
x + barWidth,
bottomY - h + radius,
);
ctx.lineTo(x + barWidth, bottomY);
ctx.lineTo(x, bottomY);
ctx.lineTo(x, bottomY - h + radius);
ctx.quadraticCurveTo(x, bottomY - h, x + radius, bottomY - h);
ctx.closePath();
ctx.fill();
}
}
function drawWave(halfBars) {
const totalWidth = canvas.width;
const maxAmp = canvas.height * 0.3;
const startX = 0;
const midY = canvas.height * 0.5;
// Primary wave
ctx.beginPath();
ctx.moveTo(startX, midY);
for (let i = 0; i < halfBars; i++) {
const t = i / (halfBars - 1);
const x = startX + t * totalWidth;
const val = smoothedValues[i] * INV_255;
const y = midY - val * maxAmp;
if (i === 0) ctx.moveTo(x, y);
else {
const prevT = (i - 1) / (halfBars - 1);
const prevX = startX + prevT * totalWidth;
const cpx = (prevX + x) / 2;
ctx.quadraticCurveTo(
prevX,
midY - smoothedValues[i - 1] * INV_255 * maxAmp,
cpx,
(midY - smoothedValues[i - 1] * INV_255 * maxAmp + y) / 2,
);
}
}
// Close to form filled area
ctx.lineTo(startX + totalWidth, midY);
ctx.lineTo(startX, midY);
ctx.closePath();
const grad = ctx.createLinearGradient(0, midY - maxAmp, 0, midY);
grad.addColorStop(0, colors.primary + "cc");
grad.addColorStop(1, colors.primary + "11");
ctx.fillStyle = grad;
ctx.fill();
// Stroke the wave line
ctx.beginPath();
for (let i = 0; i < halfBars; i++) {
const t = i / (halfBars - 1);
const x = startX + t * totalWidth;
const val = smoothedValues[i] * INV_255;
const y = midY - val * maxAmp;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = colors.primary;
ctx.lineWidth = 2.5;
ctx.shadowColor = colors.primary;
ctx.shadowBlur = 10 * glowIntensity;
ctx.stroke();
ctx.shadowBlur = 0;
// Mirror wave (secondary color, below)
ctx.beginPath();
for (let i = 0; i < halfBars; i++) {
const t = i / (halfBars - 1);
const x = startX + t * totalWidth;
const val = smoothedValues[i] * INV_255;
const y = midY + val * maxAmp * 0.6;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.lineTo(startX + totalWidth, midY);
ctx.lineTo(startX, midY);
ctx.closePath();
const grad2 = ctx.createLinearGradient(0, midY, 0, midY + maxAmp * 0.6);
grad2.addColorStop(0, colors.secondary + "11");
grad2.addColorStop(1, colors.secondary + "88");
ctx.fillStyle = grad2;
ctx.fill();
ctx.beginPath();
for (let i = 0; i < halfBars; i++) {
const t = i / (halfBars - 1);
const x = startX + t * totalWidth;
const val = smoothedValues[i] * INV_255;
const y = midY + val * maxAmp * 0.6;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = colors.secondary;
ctx.lineWidth = 2;
ctx.shadowColor = colors.secondary;
ctx.shadowBlur = 8 * glowIntensity;
ctx.stroke();
ctx.shadowBlur = 0;
}
function drawBlob(halfBars) {
blobPhase += 0.008;
const points = 64;
const maxDeform = size * 0.2;
ctx.beginPath();
for (let i = 0; i <= points; i++) {
const angle = (i / points) * Math.PI * 2;
// Map angle to frequency bin
const freqIdx = Math.floor((i / points) * halfBars) % halfBars;
const val = smoothedValues[freqIdx] * INV_255;
// Perlin-like noise via layered sin
const noise =
Math.sin(angle * 3 + blobPhase) * 0.3 +
Math.sin(angle * 5 - blobPhase * 1.3) * 0.2 +
Math.sin(angle * 7 + blobPhase * 0.7) * 0.1;
const r = baseRadius + val * maxDeform + noise * maxDeform * 0.3;
const x = centerX + Math.cos(angle) * r;
const y = centerY + Math.sin(angle) * r;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
const grad = ctx.createRadialGradient(
centerX,
centerY,
0,
centerX,
centerY,
baseRadius + maxDeform,
);
grad.addColorStop(0, colors.secondary + "dd");
grad.addColorStop(0.5, colors.primary + "88");
grad.addColorStop(1, colors.primary + "22");
ctx.fillStyle = grad;
ctx.shadowColor = colors.primary;
ctx.shadowBlur = 20 * glowIntensity;
ctx.fill();
ctx.shadowBlur = 0;
// Inner glow circle
ctx.beginPath();
ctx.arc(centerX, centerY, baseRadius * 0.95, 0, Math.PI * 2);
const innerGrad = ctx.createRadialGradient(
centerX,
centerY,
0,
centerX,
centerY,
baseRadius,
);
innerGrad.addColorStop(0, colors.secondary + "44");
innerGrad.addColorStop(1, colors.secondary + "00");
ctx.fillStyle = innerGrad;
ctx.fill();
}
function draw() {
if (!ctx) return;
const useFullViewport = vizMode === "bars" || vizMode === "wave";
const cw = useFullViewport
? window.innerWidth
: Math.ceil(size * CANVAS_PAD);
const ch = useFullViewport
? window.innerHeight
: Math.ceil(size * CANVAS_PAD);
if (canvas.width !== cw || canvas.height !== ch) {
canvas.width = cw;
canvas.height = ch;
}
ctx.clearRect(0, 0, cw, ch);
if (analyser) {
analyser.getByteFrequencyData(dataArray);
}
if (useFullViewport) {
centerX = cw / 2;
centerY = ch / 2;
} else {
const offset = (cw - size) / 2;
centerX = offset + size / 2;
centerY = offset + size / 2;
}
const { halfBars, totalBars } = getProcessedData();
applyWrapperEffects();
spawnParticles(smoothedLoudness);
// Draw based on selected mode
switch (vizMode) {
case "bars":
drawBars(halfBars);
break;
case "wave":
drawWave(halfBars);
break;
case "blob":
drawBlob(halfBars);
break;
case "circular":
default:
drawCircular(halfBars, totalBars);
break;
}
// Draw particles on top
updateAndDrawParticles();
animationId = requestAnimationFrame(draw);
}
function updateSize() {
size = Math.min(window.innerWidth, window.innerHeight) * 0.65;
baseRadius = size * 0.25;
}
export async function startListening() {
try {
await initAudio();
draw();
} catch (err) {
console.error("Failed to start audio:", err);
alert(err.message || "Failed to start audio. Check browser permissions.");
// Clean up partial state
if (audioContext) {
audioContext.close().catch(() => {});
audioContext = null;
}
isListening = false;
}
}
export function stopListening() {
if (animationId) cancelAnimationFrame(animationId);
if (audioElement) {
audioElement.pause();
audioElement.currentTime = 0;
}
if (currentStream) {
currentStream.getTracks().forEach((t) => t.stop());
currentStream = null;
}
if (audioContext) {
audioContext.close();
audioContext = null;
}
audioSourceNode = null;
isListening = false;
dataArray = new Uint8Array(bufferLength);
draw();
}
export let fileCurrentTime = 0;
export let fileDuration = 0;
export let filePlaying = false;
export let fileName = "";
export function setAudioFile(file) {
if (audioElement) {
audioElement.pause();
URL.revokeObjectURL(audioElement.src);
}
audioElement = new Audio();
audioElement.crossOrigin = "anonymous";
audioElement.src = URL.createObjectURL(file);
audioElement.loop = true;
fileName = file.name;
filePlaying = false;
fileCurrentTime = 0;
fileDuration = 0;
audioElement.addEventListener("loadedmetadata", () => {
fileDuration = audioElement.duration;
});
audioElement.addEventListener("timeupdate", () => {
fileCurrentTime = audioElement.currentTime;
});
audioElement.addEventListener("play", () => {
filePlaying = true;
});
audioElement.addEventListener("pause", () => {
filePlaying = false;
});
}
export function pauseAudio() {
if (audioElement) {
audioElement.pause();
}
}
export function resumeAudio() {
if (audioElement) {
audioElement.play();
}
}
export function seekAudio(time) {
if (audioElement) {
audioElement.currentTime = time;
}
}
export function takeScreenshot() {
if (!canvas) return null;
return canvas.toDataURL("image/png");
}
onMount(() => {
ctx = canvas.getContext("2d");
precalculateAngles();
updateSize();
window.addEventListener("resize", updateSize);
draw();
});
onDestroy(() => {
if (animationId) cancelAnimationFrame(animationId);
if (audioContext) audioContext.close();
window.removeEventListener("resize", updateSize);
});
</script>
<div class="visualizer-container">
<div class="visualizer-wrapper" bind:this={visualizerWrapper}>
<canvas
bind:this={canvas}
width={Math.ceil(size * CANVAS_PAD)}
height={Math.ceil(size * CANVAS_PAD)}
></canvas>
{#if logoUrl}
<div
class="logo-container"
style="width: {baseRadius * 2}px; height: {baseRadius * 2}px; {logoSpin
? `animation: logo-spin ${Math.max(0.5, 20 / logoSpinSpeed)}s linear infinite`
: ''}"
>
<img src={logoUrl} alt="Logo" />
</div>
{/if}
</div>
</div>
<style>
.visualizer-container {
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.visualizer-wrapper {
position: relative;
display: flex;
justify-content: center;
align-items: center;
transition: transform 0.05s linear;
will-change: transform;
z-index: 5;
}
canvas {
display: block;
}
.logo-container {
position: absolute;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
background: radial-gradient(
circle,
rgba(20, 20, 30, 0.95),
rgba(10, 10, 15, 0.98)
);
border: none;
overflow: hidden;
z-index: 6;
}
.logo-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

1348
src/lib/Controls.svelte Normal file

File diff suppressed because it is too large Load Diff

BIN
src/lib/assets/lapikud.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

7
src/main.js Normal file
View File

@@ -0,0 +1,7 @@
import App from './App.svelte';
const app = new App({
target: document.getElementById('app')
});
export default app;

6
vite.config.js Normal file
View File

@@ -0,0 +1,6 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()]
});