From a2b8ce3cdf43c7ca170080ebec8c7c75073b3351 Mon Sep 17 00:00:00 2001 From: Boidushya Date: Fri, 6 Mar 2026 04:25:56 +0530 Subject: [PATCH] feat: add kawarp as visualizer --- index.html | 1 + js/visualizer.js | 43 ++++++--- js/visualizers/kawarp.js | 200 +++++++++++++++++++++++++++++++++++++++ package-lock.json | 14 ++- package.json | 1 + 5 files changed, 245 insertions(+), 14 deletions(-) create mode 100644 js/visualizers/kawarp.js 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",