Initial commit
This commit is contained in:
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal 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
|
||||
31
index.html
Normal file
31
index.html
Normal 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
1302
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
package.json
Normal file
16
package.json
Normal 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
591
src/App.svelte
Normal 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>
|
||||
872
src/lib/AudioVisualizer.svelte
Normal file
872
src/lib/AudioVisualizer.svelte
Normal 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
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
BIN
src/lib/assets/lapikud.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
7
src/main.js
Normal file
7
src/main.js
Normal 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
6
vite.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()]
|
||||
});
|
||||
Reference in New Issue
Block a user