feat: add kawarp as visualizer
This commit is contained in:
parent
14cf949381
commit
a2b8ce3cdf
5 changed files with 245 additions and 14 deletions
|
|
@ -3662,6 +3662,7 @@
|
||||||
<option value="particles">Particles</option>
|
<option value="particles">Particles</option>
|
||||||
<option value="unknown-pleasures">Unknown Pleasures</option>
|
<option value="unknown-pleasures">Unknown Pleasures</option>
|
||||||
<option value="butterchurn">Butterchurn (Milkdrop)</option>
|
<option value="butterchurn">Butterchurn (Milkdrop)</option>
|
||||||
|
<option value="kawarp">Kawarp (Album Art)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-item" id="visualizer-mode-setting">
|
<div class="setting-item" id="visualizer-mode-setting">
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { LCDPreset } from './visualizers/lcd.js';
|
||||||
import { ParticlesPreset } from './visualizers/particles.js';
|
import { ParticlesPreset } from './visualizers/particles.js';
|
||||||
import { UnknownPleasuresWebGL } from './visualizers/unknown_pleasures_webgl.js';
|
import { UnknownPleasuresWebGL } from './visualizers/unknown_pleasures_webgl.js';
|
||||||
import { ButterchurnPreset } from './visualizers/butterchurn.js';
|
import { ButterchurnPreset } from './visualizers/butterchurn.js';
|
||||||
|
import { KawarpPreset } from './visualizers/kawarp.js';
|
||||||
import { audioContextManager } from './audio-context.js';
|
import { audioContextManager } from './audio-context.js';
|
||||||
|
|
||||||
export class Visualizer {
|
export class Visualizer {
|
||||||
|
|
@ -23,6 +24,7 @@ export class Visualizer {
|
||||||
particles: new ParticlesPreset(),
|
particles: new ParticlesPreset(),
|
||||||
'unknown-pleasures': new UnknownPleasuresWebGL(),
|
'unknown-pleasures': new UnknownPleasuresWebGL(),
|
||||||
butterchurn: new ButterchurnPreset(),
|
butterchurn: new ButterchurnPreset(),
|
||||||
|
kawarp: new KawarpPreset(),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.activePresetKey = visualizerSettings.getPreset();
|
this.activePresetKey = visualizerSettings.getPreset();
|
||||||
|
|
@ -87,10 +89,13 @@ export class Visualizer {
|
||||||
const type = preset.contextType || '2d';
|
const type = preset.contextType || '2d';
|
||||||
const currentType = this._currentContextType;
|
const currentType = this._currentContextType;
|
||||||
|
|
||||||
// If context type changed, we need to recreate the canvas
|
// Clone the canvas to get a fresh context when switching context types,
|
||||||
// (you can't get a different context type from the same canvas)
|
// or when the previous preset grabbed its own context (managesOwnContext)
|
||||||
if (this.ctx && currentType !== type) {
|
const needsClone =
|
||||||
// Clone and replace canvas to get fresh context
|
(this.ctx && currentType !== type) ||
|
||||||
|
(!this.ctx && currentType && currentType !== type);
|
||||||
|
|
||||||
|
if (needsClone) {
|
||||||
const parent = this.canvas.parentElement;
|
const parent = this.canvas.parentElement;
|
||||||
const newCanvas = this.canvas.cloneNode(true);
|
const newCanvas = this.canvas.cloneNode(true);
|
||||||
parent.replaceChild(newCanvas, this.canvas);
|
parent.replaceChild(newCanvas, this.canvas);
|
||||||
|
|
@ -98,6 +103,12 @@ export class Visualizer {
|
||||||
this.ctx = null;
|
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 (this.ctx) return;
|
||||||
|
|
||||||
if (type === 'webgl') {
|
if (type === 'webgl') {
|
||||||
|
|
@ -141,16 +152,19 @@ export class Visualizer {
|
||||||
this.audioContext.resume();
|
this.audioContext.resume();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Butterchurn if it's the active preset
|
// Set canvas dimensions before preset init so WebGL framebuffers are created at correct size
|
||||||
if (this.activePresetKey === 'butterchurn' && this.activePreset.lazyInit) {
|
|
||||||
const sourceNode = audioContextManager.getSourceNode();
|
|
||||||
this.activePreset.lazyInit(this.canvas, this.audioContext, sourceNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.resize();
|
this.resize();
|
||||||
window.addEventListener('resize', this._resizeBound);
|
window.addEventListener('resize', this._resizeBound);
|
||||||
this.canvas.style.display = 'block';
|
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();
|
this.animate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -281,10 +295,13 @@ export class Visualizer {
|
||||||
this.initContext();
|
this.initContext();
|
||||||
this.resize();
|
this.resize();
|
||||||
|
|
||||||
// Initialize Butterchurn if switching to it
|
// Initialize presets that need lazy init (Butterchurn, Kawarp)
|
||||||
if (key === 'butterchurn' && this.presets[key].lazyInit && this.audioContext) {
|
if (this.presets[key].lazyInit && this.audioContext) {
|
||||||
const sourceNode = audioContextManager.getSourceNode();
|
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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
200
js/visualizers/kawarp.js
Normal file
200
js/visualizers/kawarp.js
Normal file
|
|
@ -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 <img> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
package-lock.json
generated
14
package-lock.json
generated
|
|
@ -11,6 +11,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||||
"@ffmpeg/util": "^0.12.2",
|
"@ffmpeg/util": "^0.12.2",
|
||||||
|
"@kawarp/core": "^1.1.1",
|
||||||
"@neutralinojs/lib": "^6.5.0",
|
"@neutralinojs/lib": "^6.5.0",
|
||||||
"butterchurn": "^2.6.7",
|
"butterchurn": "^2.6.7",
|
||||||
"butterchurn-presets": "^2.4.7",
|
"butterchurn-presets": "^2.4.7",
|
||||||
|
|
@ -23,7 +24,7 @@
|
||||||
"@neutralinojs/neu": "^11.7.0",
|
"@neutralinojs/neu": "^11.7.0",
|
||||||
"eslint": "^9.39.3",
|
"eslint": "^9.39.3",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"globals": "^17.3.0",
|
"globals": "^17.4.0",
|
||||||
"htmlhint": "^1.9.1",
|
"htmlhint": "^1.9.1",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"stylelint": "^16.26.1",
|
"stylelint": "^16.26.1",
|
||||||
|
|
@ -2447,6 +2448,12 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@keyv/serialize": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
|
||||||
|
|
@ -10742,6 +10749,11 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"@keyv/serialize": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||||
"@ffmpeg/util": "^0.12.2",
|
"@ffmpeg/util": "^0.12.2",
|
||||||
|
"@kawarp/core": "^1.1.1",
|
||||||
"@neutralinojs/lib": "^6.5.0",
|
"@neutralinojs/lib": "^6.5.0",
|
||||||
"butterchurn": "^2.6.7",
|
"butterchurn": "^2.6.7",
|
||||||
"butterchurn-presets": "^2.4.7",
|
"butterchurn-presets": "^2.4.7",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue