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