diff --git a/index.html b/index.html
index 07cbe4f..91f22f9 100644
--- a/index.html
+++ b/index.html
@@ -3662,6 +3662,7 @@
+
diff --git a/js/visualizer.js b/js/visualizer.js
index f7af63a..2e5809a 100644
--- a/js/visualizer.js
+++ b/js/visualizer.js
@@ -4,6 +4,7 @@ import { LCDPreset } from './visualizers/lcd.js';
import { ParticlesPreset } from './visualizers/particles.js';
import { UnknownPleasuresWebGL } from './visualizers/unknown_pleasures_webgl.js';
import { ButterchurnPreset } from './visualizers/butterchurn.js';
+import { KawarpPreset } from './visualizers/kawarp.js';
import { audioContextManager } from './audio-context.js';
export class Visualizer {
@@ -23,6 +24,7 @@ export class Visualizer {
particles: new ParticlesPreset(),
'unknown-pleasures': new UnknownPleasuresWebGL(),
butterchurn: new ButterchurnPreset(),
+ kawarp: new KawarpPreset(),
};
this.activePresetKey = visualizerSettings.getPreset();
@@ -87,10 +89,13 @@ export class Visualizer {
const type = preset.contextType || '2d';
const currentType = this._currentContextType;
- // If context type changed, we need to recreate the canvas
- // (you can't get a different context type from the same canvas)
- if (this.ctx && currentType !== type) {
- // Clone and replace canvas to get fresh context
+ // Clone the canvas to get a fresh context when switching context types,
+ // or when the previous preset grabbed its own context (managesOwnContext)
+ const needsClone =
+ (this.ctx && currentType !== type) ||
+ (!this.ctx && currentType && currentType !== type);
+
+ if (needsClone) {
const parent = this.canvas.parentElement;
const newCanvas = this.canvas.cloneNode(true);
parent.replaceChild(newCanvas, this.canvas);
@@ -98,6 +103,12 @@ export class Visualizer {
this.ctx = null;
}
+ // Kawarp grabs its own WebGL context, so we skip this
+ if (preset.managesOwnContext) {
+ this._currentContextType = type;
+ return;
+ }
+
if (this.ctx) return;
if (type === 'webgl') {
@@ -141,16 +152,19 @@ export class Visualizer {
this.audioContext.resume();
}
- // Initialize Butterchurn if it's the active preset
- if (this.activePresetKey === 'butterchurn' && this.activePreset.lazyInit) {
- const sourceNode = audioContextManager.getSourceNode();
- this.activePreset.lazyInit(this.canvas, this.audioContext, sourceNode);
- }
-
+ // Set canvas dimensions before preset init so WebGL framebuffers are created at correct size
this.resize();
window.addEventListener('resize', this._resizeBound);
this.canvas.style.display = 'block';
+ // Initialize presets that need lazy init (Butterchurn, Kawarp)
+ if (this.activePreset.lazyInit) {
+ const sourceNode = audioContextManager.getSourceNode();
+ this.activePreset.lazyInit(this.canvas, this.audioContext, sourceNode).then(() => {
+ this.resize();
+ });
+ }
+
this.animate();
}
@@ -281,10 +295,13 @@ export class Visualizer {
this.initContext();
this.resize();
- // Initialize Butterchurn if switching to it
- if (key === 'butterchurn' && this.presets[key].lazyInit && this.audioContext) {
+ // Initialize presets that need lazy init (Butterchurn, Kawarp)
+ if (this.presets[key].lazyInit && this.audioContext) {
const sourceNode = audioContextManager.getSourceNode();
- this.presets[key].lazyInit(this.canvas, this.audioContext, sourceNode);
+ this.presets[key].lazyInit(this.canvas, this.audioContext, sourceNode).then(() => {
+ // Re-resize after async init so framebuffers match canvas size
+ this.resize();
+ });
}
}
}
diff --git a/js/visualizers/kawarp.js b/js/visualizers/kawarp.js
new file mode 100644
index 0000000..a8122ea
--- /dev/null
+++ b/js/visualizers/kawarp.js
@@ -0,0 +1,200 @@
+// js/visualizers/kawarp.js
+
+const KAWARP_DEFAULTS = {
+ warpIntensity: 1,
+ blurPasses: 8,
+ animationSpeed: 1,
+ transitionDuration: 1000,
+ saturation: 1.5,
+ dithering: 0.008,
+ scale: 1.25,
+};
+
+const BEAT_THRESHOLD = 0.75;
+const SPEED_MULTIPLIER = 4;
+const SCALE_BOOST_PCT = 2;
+const SCALE_LERP_UP = 0.5;
+const SCALE_LERP_DOWN = 0.12;
+const SCALE_THRESHOLD = 0.001;
+const ANALYSIS_INTERVAL = 100;
+
+export class KawarpPreset {
+ constructor() {
+ this.name = 'Kawarp';
+ this.contextType = 'webgl';
+ this.managesOwnContext = true;
+
+ this.kawarp = null;
+ this.canvas = null;
+ this.audioElement = null;
+ this.isInitialized = false;
+ this._lastCoverUrl = null;
+ this._currentScale = KAWARP_DEFAULTS.scale;
+ this._targetScale = KAWARP_DEFAULTS.scale;
+ this._lastAnalysisTime = 0;
+ this._coverObserver = null;
+
+ this._onPlay = () => {
+ if (this.kawarp) this.kawarp.start();
+ };
+ this._onPause = () => {
+ if (this.kawarp) this.kawarp.stop();
+ };
+ }
+
+ async lazyInit(canvas, _audioContext, _sourceNode) {
+ if (this.isInitialized) {
+ if (canvas !== this.canvas) {
+ this._destroyKawarp();
+ } else {
+ this._ensureStarted();
+ return;
+ }
+ }
+
+ try {
+ const { Kawarp } = await import('@kawarp/core');
+
+ this.canvas = canvas;
+ this.kawarp = new Kawarp(canvas, { ...KAWARP_DEFAULTS });
+
+ this.audioElement = document.getElementById('audio-player');
+ if (this.audioElement) {
+ this.audioElement.addEventListener('play', this._onPlay);
+ this.audioElement.addEventListener('pause', this._onPause);
+ }
+
+ this._observeCoverArt();
+
+ const coverEl = document.querySelector('.now-playing-bar .cover');
+ if (coverEl?.tagName === 'IMG' && coverEl.src) {
+ this._lastCoverUrl = coverEl.src;
+ this._loadCover(coverEl.src);
+ }
+
+ this.kawarp.start();
+ this.isInitialized = true;
+ } catch (error) {
+ console.error('[Kawarp] Init failed:', error);
+ }
+ }
+
+ connectAudio() {}
+
+ _ensureStarted() {
+ if (!this.kawarp) return;
+ if (this.kawarp.isPlaying) return;
+ if (this.audioElement?.paused) return;
+ this.kawarp.start();
+ }
+
+ _observeCoverArt() {
+ const container = document.querySelector('.now-playing-bar');
+ if (!container) return;
+
+ this._coverObserver = new MutationObserver(() => {
+ const el = document.querySelector('.now-playing-bar .cover');
+ const src = el?.tagName === 'IMG' ? el.src : null;
+ if (!src || src === this._lastCoverUrl) return;
+ this._lastCoverUrl = src;
+ if (this.kawarp && this.isInitialized) {
+ this._loadCover(src);
+ }
+ });
+
+ this._coverObserver.observe(container, {
+ attributes: true,
+ attributeFilter: ['src'],
+ subtree: true,
+ childList: true,
+ });
+ }
+
+ _loadCover(url) {
+ // Cache buster forces a fresh CORS request, bypassing the browser's
+ // cached non-CORS response from the
![]()
tag (same pattern as ui.js)
+ const sep = url.includes('?') ? '&' : '?';
+ this.kawarp.loadImage(`${url}${sep}not-from-cache-please`).catch((err) =>
+ console.warn('[Kawarp] Failed to load cover:', err),
+ );
+ }
+
+ resize(_w, _h) {
+ if (this.kawarp) this.kawarp.resize();
+ }
+
+ draw(ctx, canvas, analyser, dataArray, stats) {
+ if (!this.kawarp || !this.isInitialized) return;
+
+ this._ensureStarted();
+
+ // Beat detection, throttled to every 100ms
+ const now = performance.now();
+ if (analyser && now - this._lastAnalysisTime >= ANALYSIS_INTERVAL) {
+ const buf = new Uint8Array(analyser.frequencyBinCount);
+ analyser.getByteTimeDomainData(buf);
+
+ let peak = 0;
+ for (let i = 0; i < buf.length; i++) {
+ const a = Math.abs(buf[i] - 128) / 128;
+ if (a > peak) {
+ peak = a;
+ if (peak > BEAT_THRESHOLD) break;
+ }
+ }
+
+ const isBeat = peak > BEAT_THRESHOLD;
+
+ this.kawarp.animationSpeed = isBeat
+ ? KAWARP_DEFAULTS.animationSpeed * SPEED_MULTIPLIER
+ : KAWARP_DEFAULTS.animationSpeed;
+
+ this._targetScale = isBeat ? KAWARP_DEFAULTS.scale + SCALE_BOOST_PCT / 100 : KAWARP_DEFAULTS.scale;
+
+ this._lastAnalysisTime = now;
+ }
+
+ // Scale lerp
+ const diff = this._targetScale - this._currentScale;
+ if (Math.abs(diff) > SCALE_THRESHOLD) {
+ const lerp = diff > 0 ? SCALE_LERP_UP : SCALE_LERP_DOWN;
+ this._currentScale += diff * lerp;
+ this.kawarp.scale = this._currentScale;
+ }
+
+ // Blended mode support
+ if (stats.mode === 'blended') {
+ canvas.style.opacity = '0.85';
+ canvas.style.mixBlendMode = 'screen';
+ } else {
+ canvas.style.opacity = '1';
+ canvas.style.mixBlendMode = 'normal';
+ }
+ }
+
+ _destroyKawarp() {
+ if (this.kawarp) {
+ this.kawarp.stop();
+ this.kawarp.dispose();
+ this.kawarp = null;
+ }
+ this.canvas = null;
+ this.isInitialized = false;
+ }
+
+ destroy() {
+ if (this._coverObserver) {
+ this._coverObserver.disconnect();
+ this._coverObserver = null;
+ }
+ if (this.audioElement) {
+ this.audioElement.removeEventListener('play', this._onPlay);
+ this.audioElement.removeEventListener('pause', this._onPause);
+ this.audioElement = null;
+ }
+ this._destroyKawarp();
+ this._lastCoverUrl = null;
+ this._currentScale = KAWARP_DEFAULTS.scale;
+ this._targetScale = KAWARP_DEFAULTS.scale;
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 84aff55..56397ea 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
+ "@kawarp/core": "^1.1.1",
"@neutralinojs/lib": "^6.5.0",
"butterchurn": "^2.6.7",
"butterchurn-presets": "^2.4.7",
@@ -23,7 +24,7 @@
"@neutralinojs/neu": "^11.7.0",
"eslint": "^9.39.3",
"eslint-config-prettier": "^10.1.8",
- "globals": "^17.3.0",
+ "globals": "^17.4.0",
"htmlhint": "^1.9.1",
"prettier": "^3.8.1",
"stylelint": "^16.26.1",
@@ -2447,6 +2448,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@kawarp/core": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@kawarp/core/-/core-1.1.1.tgz",
+ "integrity": "sha512-hnJ0CQQAa6o4HPoUE6Tkn6/cqzpA/tRPNDTNqVeoY9rozL37KweAzbypmdrYTBOdyJRR9MvETyxy4hlpenIa/w==",
+ "license": "AGPL-3.0"
+ },
"node_modules/@keyv/serialize": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
@@ -10742,6 +10749,11 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "@kawarp/core": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@kawarp/core/-/core-1.1.1.tgz",
+ "integrity": "sha512-hnJ0CQQAa6o4HPoUE6Tkn6/cqzpA/tRPNDTNqVeoY9rozL37KweAzbypmdrYTBOdyJRR9MvETyxy4hlpenIa/w=="
+ },
"@keyv/serialize": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
diff --git a/package.json b/package.json
index 6e111fc..88a3095 100644
--- a/package.json
+++ b/package.json
@@ -49,6 +49,7 @@
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
+ "@kawarp/core": "^1.1.1",
"@neutralinojs/lib": "^6.5.0",
"butterchurn": "^2.6.7",
"butterchurn-presets": "^2.4.7",