Minor update

This commit is contained in:
AlacrisDevs
2026-02-18 10:02:20 +02:00
parent 2d1e3a401f
commit 21df667646
6 changed files with 213 additions and 104 deletions

View File

@@ -1,42 +1,57 @@
# sv
# Audio Visualizer
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
A real-time audio visualizer built with SvelteKit, Canvas API, and Web Audio API. Capture audio from your microphone, browser tab, or a local file and watch it come to life with reactive visuals, particles, and customizable themes.
## Creating a project
## Features
If you're seeing this, you've probably already done this step. Congrats!
- **4 Visualization Modes** — Circular, Bars, Wave, and Blob
- **3 Audio Sources** — Microphone, Browser Tab (Chrome/Edge), and Local File
- **Particle System** — Configurable density, size, rotation, and custom particle images
- **Audio Reactivity** — Sensitivity, smoothing, and bass emphasis controls
- **Visual Effects** — Shake, zoom, and glow intensity sliders
- **Color Presets & Custom Colors** — 6 built-in presets or pick your own gradient
- **Branding** — Custom logo with optional spin, title, and subtitle with positioning
- **Background** — Custom image, color picker, and vignette toggle
- **Theme System** — Save, load, import, and export themes as `.vslzr` files
- **Screenshot Export** — Save the current visualization as a PNG
- **Fullscreen Mode** — Immersive fullscreen with keyboard shortcut
- **i18n** — English and Estonian via Paraglide.js
- **Persistent Settings** — All settings saved to localStorage
## Quick Start
```sh
# create a new project
npx sv create my-app
```
# Clone the repo
git clone https://git.lapikud.ee/sass/audio-visualizer.git
cd audio-visualizer
To recreate this project with the same configuration:
# Install dependencies
npm install
```sh
# recreate this project
npx sv create --template minimal --types ts --add tailwindcss="plugins:typography,forms" paraglide="languageTags:en, et+demo:no" --install npm audio-visualizer-v2
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
# Start dev server
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
Open [http://localhost:5173](http://localhost:5173) in your browser.
To create a production version of your app:
## Keyboard Shortcuts
| Key | Action |
| --- | --- |
| `Space` | Play / Stop |
| `F` | Toggle Fullscreen |
| `H` | Hide / Show Sidebar |
## Build
```sh
npm run build
npm run preview
```
You can preview the production build with `npm run preview`.
## Tech Stack
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
- **SvelteKit** + **Svelte 5** (runes)
- **Tailwind CSS v4**
- **Canvas API** + **Web Audio API**
- **Paraglide.js** for i18n

19
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^25.2.3",
"svelte": "^5.49.2",
"svelte-check": "^4.3.6",
"tailwindcss": "^4.1.18",
@@ -1397,6 +1398,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.2.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -2410,6 +2422,13 @@
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
"node_modules/unplugin": {
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",

View File

@@ -19,10 +19,11 @@
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^25.2.3",
"svelte": "^5.49.2",
"svelte-check": "^4.3.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.1"
}
}
}

View File

@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { onMount, onDestroy } from "svelte";
let {
@@ -38,14 +38,26 @@
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;
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
life: number;
decay: number;
radius: number;
angle: number;
spin: number;
}
let canvas: HTMLCanvasElement;
let ctx: CanvasRenderingContext2D;
let animationId: number | undefined;
let audioContext: AudioContext | null = null;
let analyser: AnalyserNode | null = null;
let currentStream: MediaStream | null = null;
let audioElement: HTMLAudioElement | null = null;
let audioSourceNode: MediaElementAudioSourceNode | null = null;
let dataArray = new Uint8Array(128);
let bufferLength = 128;
@@ -59,11 +71,11 @@
let smoothedValues = new Float32Array(Math.ceil(barCount / 2));
let smoothedScale = MIN_SCALE;
let smoothedLoudness = 0;
let visualizerWrapper;
let visualizerWrapper: HTMLDivElement;
let blobPhase = 0;
// Particle image cache
let pImg = $state(null);
let pImg: HTMLImageElement | null = $state(null);
$effect(() => {
if (particleImage) {
const img = new Image();
@@ -75,7 +87,7 @@
});
// Particle system
let particles = [];
let particles: Particle[] = [];
// Recalculate angles when barCount changes
let prevBarCount = barCount;
@@ -105,7 +117,7 @@
}
});
function spawnParticles(loudness) {
function spawnParticles(loudness: number) {
if (!particlesEnabled) return;
const count = Math.floor(loudness * 8 * particleDensity);
const maxP = Math.floor(MAX_PARTICLES * particleDensity);
@@ -113,7 +125,7 @@
for (let j = 0; j < count; j++) {
if (particles.length >= maxP) break;
let p;
let p: Particle;
switch (vizMode) {
case "bars": {
const halfBars = Math.ceil(barCount / 2);
@@ -138,6 +150,8 @@
life: 1.0,
decay: 0.008 + Math.random() * 0.02,
radius: (2 + Math.random() * 5) * particleSize,
angle: 0,
spin: 0,
};
break;
}
@@ -161,6 +175,8 @@
life: 1.0,
decay: 0.006 + Math.random() * 0.012,
radius: (2 + Math.random() * 4) * particleSize,
angle: 0,
spin: 0,
};
break;
}
@@ -183,6 +199,8 @@
life: 1.0,
decay: 0.006 + Math.random() * 0.015,
radius: (2 + Math.random() * 6) * particleSize,
angle: 0,
spin: 0,
};
break;
}
@@ -199,6 +217,8 @@
life: 1.0,
decay: 0.005 + Math.random() * 0.015,
radius: (3 + Math.random() * 7) * particleSize,
angle: 0,
spin: 0,
};
break;
}
@@ -271,7 +291,8 @@
}
async function initAudio() {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
audioContext = new (window.AudioContext ||
(window as any).webkitAudioContext)();
if (audioContext.state === "suspended") {
await audioContext.resume();
}
@@ -298,7 +319,7 @@
noiseSuppression: false,
autoGainControl: false,
systemAudio: "include",
},
} as MediaTrackConstraints,
});
const audioTracks = stream.getAudioTracks();
if (audioTracks.length === 0) {
@@ -383,7 +404,7 @@
}
}
function drawCircular(halfBars, totalBars) {
function drawCircular(halfBars: number, totalBars: number) {
const maxBarHeight = size * 0.25;
const barHeights = new Float32Array(totalBars);
for (let i = 0; i < totalBars; i++) {
@@ -433,7 +454,7 @@
ctx.fill("evenodd");
}
function drawBars(halfBars) {
function drawBars(halfBars: number) {
const totalBarsToShow = halfBars;
const barGap = 3;
const totalWidth = canvas.width;
@@ -476,7 +497,7 @@
}
}
function drawWave(halfBars) {
function drawWave(halfBars: number) {
const totalWidth = canvas.width;
const maxAmp = canvas.height * 0.3;
const startX = 0;
@@ -558,7 +579,7 @@
ctx.shadowBlur = 0;
}
function drawBlob(halfBars) {
function drawBlob(halfBars: number) {
blobPhase += 0.008;
const points = 64;
const maxDeform = size * 0.2;
@@ -668,7 +689,10 @@
draw();
} catch (err) {
console.error("Failed to start audio:", err);
alert(err.message || "Failed to start audio. Check browser permissions.");
alert(
(err as Error).message ||
"Failed to start audio. Check browser permissions.",
);
if (audioContext) {
audioContext.close().catch(() => {});
audioContext = null;
@@ -697,7 +721,7 @@
draw();
}
export function setAudioFile(file) {
export function setAudioFile(file: File) {
if (audioElement) {
audioElement.pause();
URL.revokeObjectURL(audioElement.src);
@@ -711,10 +735,10 @@
fileCurrentTime = 0;
fileDuration = 0;
audioElement.addEventListener("loadedmetadata", () => {
fileDuration = audioElement.duration;
fileDuration = audioElement!.duration;
});
audioElement.addEventListener("timeupdate", () => {
fileCurrentTime = audioElement.currentTime;
fileCurrentTime = audioElement!.currentTime;
});
audioElement.addEventListener("play", () => {
filePlaying = true;
@@ -732,7 +756,7 @@
if (audioElement) audioElement.play();
}
export function seekAudio(time) {
export function seekAudio(time: number) {
if (audioElement) audioElement.currentTime = time;
}
@@ -742,7 +766,7 @@
}
onMount(() => {
ctx = canvas.getContext("2d");
ctx = canvas.getContext("2d")!;
precalculateAngles();
updateSize();
window.addEventListener("resize", updateSize);

View File

@@ -1,7 +1,56 @@
<script>
<script lang="ts">
import * as m from "$lib/paraglide/messages.js";
import { getLocale, setLocale, locales } from "$lib/paraglide/runtime.js";
interface Props {
selectedPreset?: string;
logoUrl?: string;
title?: string;
subtitle?: string;
bgImage?: string;
bgColor?: string;
vignette?: boolean;
audioSource?: string;
customPrimary?: string;
customSecondary?: string;
useCustomColors?: boolean;
isListening?: boolean;
colorPresets?: Record<
string,
{ name: string; primary: string; secondary: string }
>;
onToggle?: () => void;
fileCurrentTime?: number;
fileDuration?: number;
filePlaying?: boolean;
fileName?: string;
vizMode?: string;
barCount?: number;
sensitivity?: number;
smoothing?: number;
bassEmphasis?: number;
particlesEnabled?: boolean;
particleDensity?: number;
shakeAmount?: number;
zoomIntensity?: number;
glowIntensity?: number;
logoSpin?: boolean;
logoSpinSpeed?: number;
particleImage?: string;
particleSize?: number;
particleRotation?: number;
titlePosition?: string;
savedThemes?: any[];
onscreenshot?: () => void;
onfullscreen?: () => void;
onreset?: () => void;
onaudiofile?: (file: File) => void;
onfilepause?: () => void;
onfileresume?: () => void;
onfileseek?: (time: number) => void;
onthemeschanged?: (themes: any[]) => void;
}
let {
selectedPreset = $bindable("cyanPink"),
logoUrl = $bindable(""),
@@ -37,22 +86,22 @@
particleSize = $bindable(1.0),
particleRotation = $bindable(0),
titlePosition = $bindable("top"),
savedThemes = $bindable([]),
savedThemes = $bindable<any[]>([]),
onscreenshot = () => {},
onfullscreen = () => {},
onreset = () => {},
onaudiofile = (file) => {},
onaudiofile = (_file: File) => {},
onfilepause = () => {},
onfileresume = () => {},
onfileseek = (time) => {},
onthemeschanged = (themes) => {},
} = $props();
onfileseek = (_time: number) => {},
onthemeschanged = (_themes: any[]) => {},
}: Props = $props();
let fileInput = $state(null);
let bgFileInput = $state(null);
let audioFileInput = $state(null);
let particleImgInput = $state(null);
let themeFileInput = $state(null);
let fileInput: HTMLInputElement | null = $state(null);
let bgFileInput: HTMLInputElement | null = $state(null);
let audioFileInput: HTMLInputElement | null = $state(null);
let particleImgInput: HTMLInputElement | null = $state(null);
let themeFileInput: HTMLInputElement | null = $state(null);
let dragOver = $state(false);
let newThemeName = $state("");
@@ -62,7 +111,7 @@
const supportsTabAudio = !isFirefox;
const vizModeKeys = ["circular", "bars", "wave", "blob"];
function vizModeName(key) {
function vizModeName(key: string) {
switch (key) {
case "circular":
return m.circular();
@@ -77,48 +126,48 @@
}
}
function readFileAsDataUrl(file, callback) {
function readFileAsDataUrl(file: File, callback: (result: string) => void) {
const reader = new FileReader();
reader.onload = (e) => callback(e.target.result);
reader.onload = (e) => callback(e.target!.result as string);
reader.readAsDataURL(file);
}
function formatTime(seconds) {
function formatTime(seconds: number) {
if (!seconds || !isFinite(seconds)) return "0:00";
const min = Math.floor(seconds / 60);
const sec = Math.floor(seconds % 60);
return `${min}:${sec.toString().padStart(2, "0")}`;
}
function handleFileUpload(e) {
const file = e.target.files[0];
function handleFileUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) readFileAsDataUrl(file, (url) => (logoUrl = url));
}
function handleParticleImgUpload(e) {
const file = e.target.files[0];
function handleParticleImgUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) readFileAsDataUrl(file, (url) => (particleImage = url));
}
function handleBgUpload(e) {
const file = e.target.files[0];
function handleBgUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) readFileAsDataUrl(file, (url) => (bgImage = url));
}
function handleAudioFile(e) {
const file = e.target.files[0];
function handleAudioFile(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (file && file.type.startsWith("audio/")) {
audioSource = "file";
onaudiofile(file);
}
}
function handleDrop(e) {
function handleDrop(e: DragEvent) {
e.preventDefault();
dragOver = false;
const file = e.dataTransfer.files[0];
const file = e.dataTransfer?.files[0];
if (file && file.type.startsWith("audio/")) {
audioSource = "file";
onaudiofile(file);
}
}
function handleDragOver(e) {
function handleDragOver(e: DragEvent) {
e.preventDefault();
dragOver = true;
}
@@ -132,7 +181,7 @@
if (bgFileInput) bgFileInput.value = "";
}
function selectPreset(key) {
function selectPreset(key: string) {
selectedPreset = key;
useCustomColors = false;
}
@@ -140,8 +189,8 @@
useCustomColors = true;
}
function handleSeek(e) {
onfileseek(parseFloat(e.target.value));
function handleSeek(e: Event) {
onfileseek(parseFloat((e.target as HTMLInputElement).value));
}
function toggleFilePlayback() {
if (filePlaying) onfilepause();
@@ -185,7 +234,7 @@
onthemeschanged(savedThemes);
}
function loadTheme(theme) {
function loadTheme(theme: any) {
selectedPreset = theme.selectedPreset ?? selectedPreset;
logoUrl = theme.logoUrl ?? logoUrl;
title = theme.title ?? title;
@@ -214,12 +263,12 @@
titlePosition = theme.titlePosition ?? titlePosition;
}
function deleteTheme(id) {
function deleteTheme(id: number) {
savedThemes = savedThemes.filter((t) => t.id !== id);
onthemeschanged(savedThemes);
}
function exportTheme(theme) {
function exportTheme(theme: any) {
const data = JSON.stringify(theme, null, 2);
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
@@ -230,13 +279,13 @@
URL.revokeObjectURL(url);
}
function handleThemeImport(e) {
const file = e.target.files[0];
function handleThemeImport(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const theme = JSON.parse(ev.target.result);
const theme = JSON.parse(ev.target!.result as string);
if (!theme.name) theme.name = file.name.replace(/\.vslzr$/, "");
theme.id = Date.now();
savedThemes = [...savedThemes, theme];
@@ -246,7 +295,7 @@
}
};
reader.readAsText(file);
e.target.value = "";
(e.target as HTMLInputElement).value = "";
}
</script>
@@ -332,7 +381,7 @@
'file'
? 'bg-white/15 text-white'
: 'bg-white/5 text-zinc-500 hover:bg-white/10'}"
onclick={() => audioFileInput.click()}>{m.file()}</button
onclick={() => audioFileInput?.click()}>{m.file()}</button
>
</div>
@@ -927,7 +976,7 @@
/>
<button
class="flex-1 px-3 py-1.5 bg-white/[0.06] border border-white/15 rounded-lg text-zinc-400 text-xs hover:bg-white/10 hover:text-white transition-all"
onclick={() => themeFileInput.click()}
onclick={() => themeFileInput?.click()}
>
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { browser } from "$app/environment";
import AudioVisualizer from "$lib/AudioVisualizer.svelte";
@@ -90,9 +90,9 @@
let particleRotation = $state(saved.particleRotation);
let titlePosition = $state(saved.titlePosition);
let savedThemes = $state(loadThemes());
let visualizerComponent = $state(null);
let visualizerComponent: any = $state(null);
let toggleHidden = $state(false);
let hideTimer;
let hideTimer: ReturnType<typeof setTimeout>;
let fileCurrentTime = $state(0);
let fileDuration = $state(0);
@@ -162,7 +162,7 @@
} catch {}
});
function handleThemesChanged(themes) {
function handleThemesChanged(themes: any[]) {
savedThemes = themes;
if (browser) {
try {
@@ -173,14 +173,14 @@
function toggleListening() {
if (isListening) {
visualizerComponent.stopListening();
visualizerComponent?.stopListening();
} else {
visualizerComponent.startListening();
visualizerComponent?.startListening();
}
}
function handleScreenshot() {
const dataUrl = visualizerComponent.takeScreenshot();
const dataUrl = visualizerComponent?.takeScreenshot();
if (!dataUrl) return;
const link = document.createElement("a");
link.download = "visualizer-screenshot.png";
@@ -226,7 +226,7 @@
titlePosition = defaults.titlePosition;
}
function handleAudioFile(file) {
function handleAudioFile(file: File) {
if (file && visualizerComponent) {
if (isListening) visualizerComponent.stopListening();
audioSource = "file";
@@ -240,15 +240,16 @@
function handleFileResume() {
if (visualizerComponent) visualizerComponent.resumeAudio();
}
function handleFileSeek(time) {
function handleFileSeek(time: number) {
if (visualizerComponent) visualizerComponent.seekAudio(time);
}
function handleKeydown(e) {
function handleKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement;
if (
e.target.tagName === "INPUT" ||
e.target.tagName === "TEXTAREA" ||
e.target.isContentEditable
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
)
return;
switch (e.code) {
@@ -306,7 +307,7 @@
let currentColors = $derived(
useCustomColors
? { primary: customPrimary, secondary: customSecondary }
: colorPresets[selectedPreset],
: colorPresets[selectedPreset as keyof typeof colorPresets],
);
</script>