mirror of
https://github.com/captbaritone/webamp.git
synced 2026-07-18 00:55:54 +00:00
Merge a2dd6c2313 into 0882aa7a31
This commit is contained in:
commit
b46e449637
2 changed files with 830 additions and 89 deletions
|
|
@ -4,50 +4,16 @@ import * as Actions from "../actionCreators";
|
|||
import * as Selectors from "../selectors";
|
||||
import { useTypedSelector, useActionCreator } from "../hooks";
|
||||
import { VISUALIZERS, MEDIA_STATUS } from "../constants";
|
||||
|
||||
import {
|
||||
Vis as IVis,
|
||||
BarPaintHandler,
|
||||
WavePaintHandler,
|
||||
NoVisualizerHandler,
|
||||
} from "./VisPainter";
|
||||
import { createVisualizerEngine } from "./VisualizerEngine";
|
||||
|
||||
type Props = {
|
||||
analyser: AnalyserNode;
|
||||
};
|
||||
|
||||
// Pre-render the background grid
|
||||
function preRenderBg(options: {
|
||||
width: number;
|
||||
height: number;
|
||||
bgColor: string;
|
||||
fgColor: string;
|
||||
windowShade: boolean;
|
||||
pixelDensity: number;
|
||||
}): HTMLCanvasElement {
|
||||
const { width, height, bgColor, fgColor, windowShade, pixelDensity } =
|
||||
options;
|
||||
// Off-screen canvas for pre-rendering the background
|
||||
const bgCanvas = document.createElement("canvas");
|
||||
bgCanvas.width = width;
|
||||
bgCanvas.height = height;
|
||||
const distance = 2 * pixelDensity;
|
||||
|
||||
const bgCanvasCtx = bgCanvas.getContext("2d");
|
||||
if (bgCanvasCtx == null) {
|
||||
throw new Error("Could not construct canvas context");
|
||||
}
|
||||
bgCanvasCtx.fillStyle = bgColor;
|
||||
bgCanvasCtx.fillRect(0, 0, width, height);
|
||||
if (!windowShade) {
|
||||
bgCanvasCtx.fillStyle = fgColor;
|
||||
for (let x = 0; x < width; x += distance) {
|
||||
for (let y = pixelDensity; y < height; y += distance) {
|
||||
bgCanvasCtx.fillRect(x, y, pixelDensity, pixelDensity);
|
||||
}
|
||||
}
|
||||
}
|
||||
return bgCanvas;
|
||||
function resolveMode(mode: unknown): "bars" | "oscilloscope" | "none" {
|
||||
if (mode === VISUALIZERS.BAR) return "bars";
|
||||
if (mode === VISUALIZERS.OSCILLOSCOPE) return "oscilloscope";
|
||||
return "none";
|
||||
}
|
||||
|
||||
export default function Vis({ analyser }: Props) {
|
||||
|
|
@ -60,10 +26,10 @@ export default function Vis({ analyser }: Props) {
|
|||
const audioStatus = useTypedSelector(Selectors.getMediaStatus);
|
||||
const getWindowShade = useTypedSelector(Selectors.getWindowShade);
|
||||
const getWindowOpen = useTypedSelector(Selectors.getWindowOpen);
|
||||
const isMWOpen = getWindowOpen("main");
|
||||
const isMWOpen = getWindowOpen("main") ?? false;
|
||||
const doubled = useTypedSelector(Selectors.getDoubled);
|
||||
const toggleVisualizerStyle = useActionCreator(Actions.toggleVisualizerStyle);
|
||||
const windowShade = getWindowShade("main");
|
||||
const windowShade = getWindowShade("main") ?? false;
|
||||
|
||||
const smallVis = windowShade && isMWOpen;
|
||||
const renderHeight = smallVis ? 5 : 16;
|
||||
|
|
@ -82,62 +48,27 @@ export default function Vis({ analyser }: Props) {
|
|||
const width = renderWidth * pixelDensity;
|
||||
const height = renderHeight * pixelDensity;
|
||||
|
||||
const bgCanvas = useMemo(() => {
|
||||
return preRenderBg({
|
||||
width: renderWidthBG,
|
||||
height,
|
||||
bgColor: colors[0],
|
||||
fgColor: colors[1],
|
||||
windowShade: Boolean(windowShade),
|
||||
pixelDensity,
|
||||
});
|
||||
}, [colors, height, renderWidthBG, windowShade, pixelDensity]);
|
||||
|
||||
const [canvas, setCanvas] = useState<HTMLCanvasElement | null>(null);
|
||||
|
||||
//? painter administration
|
||||
const painter = useMemo(() => {
|
||||
if (!canvas) return null;
|
||||
|
||||
const vis: IVis = {
|
||||
return createVisualizerEngine({
|
||||
canvas,
|
||||
colors,
|
||||
analyser,
|
||||
colors,
|
||||
mode: resolveMode(mode),
|
||||
renderHeight: 16,
|
||||
smallVis: false,
|
||||
pixelDensity: 1,
|
||||
doubled: false,
|
||||
isMWOpen: false,
|
||||
peaks: true,
|
||||
oscStyle: "lines",
|
||||
bandwidth: "wide",
|
||||
coloring: "normal",
|
||||
peaks: true,
|
||||
saFalloff: "moderate",
|
||||
saPeakFalloff: "slow",
|
||||
sa: "analyzer", // unused, but hopefully will be used in the future for providing config options
|
||||
renderHeight,
|
||||
smallVis,
|
||||
pixelDensity,
|
||||
doubled,
|
||||
isMWOpen,
|
||||
};
|
||||
|
||||
switch (mode) {
|
||||
case VISUALIZERS.OSCILLOSCOPE:
|
||||
return new WavePaintHandler(vis);
|
||||
case VISUALIZERS.BAR:
|
||||
return new BarPaintHandler(vis);
|
||||
case VISUALIZERS.NONE:
|
||||
return new NoVisualizerHandler(vis);
|
||||
default:
|
||||
return new NoVisualizerHandler(vis);
|
||||
}
|
||||
}, [
|
||||
analyser,
|
||||
canvas,
|
||||
mode,
|
||||
colors,
|
||||
renderHeight,
|
||||
smallVis,
|
||||
pixelDensity,
|
||||
doubled,
|
||||
isMWOpen,
|
||||
]);
|
||||
});
|
||||
}, [analyser, canvas, mode, colors]);
|
||||
|
||||
// reacts to changes in doublesize mode
|
||||
useEffect(() => {
|
||||
|
|
@ -157,6 +88,18 @@ export default function Vis({ analyser }: Props) {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [doubled, canvas, painter]);
|
||||
|
||||
// updates painter configuration when layout changes (windowShade, main window open/closed)
|
||||
// without recreating the painter, preserving visualizer state
|
||||
useEffect(() => {
|
||||
if (painter) {
|
||||
painter.updateConfig({
|
||||
doubled,
|
||||
isMWOpen,
|
||||
smallVis,
|
||||
});
|
||||
}
|
||||
}, [doubled, isMWOpen, smallVis, painter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canvas == null || painter == null) {
|
||||
return;
|
||||
|
|
@ -171,7 +114,6 @@ export default function Vis({ analyser }: Props) {
|
|||
let animationRequest: number | null = null;
|
||||
|
||||
const loop = () => {
|
||||
canvasCtx.drawImage(bgCanvas, 0, 0);
|
||||
painter.paintFrame();
|
||||
animationRequest = window.requestAnimationFrame(loop);
|
||||
};
|
||||
|
|
@ -189,7 +131,7 @@ export default function Vis({ analyser }: Props) {
|
|||
window.cancelAnimationFrame(animationRequest);
|
||||
}
|
||||
};
|
||||
}, [audioStatus, canvas, painter, bgCanvas, renderWidthBG, height, mode]);
|
||||
}, [audioStatus, canvas, painter, renderWidthBG, height, mode]);
|
||||
|
||||
if (audioStatus === MEDIA_STATUS.STOPPED) {
|
||||
return null;
|
||||
|
|
|
|||
799
packages/webamp/js/components/VisualizerEngine.ts
Normal file
799
packages/webamp/js/components/VisualizerEngine.ts
Normal file
|
|
@ -0,0 +1,799 @@
|
|||
import { FFT } from "./FFTNullsoft";
|
||||
|
||||
export type VEConfig = {
|
||||
canvas: HTMLCanvasElement;
|
||||
analyser?: AnalyserNode | null;
|
||||
colors: string[];
|
||||
mode: "bars" | "oscilloscope" | "none";
|
||||
renderHeight: number;
|
||||
smallVis: boolean;
|
||||
pixelDensity: number;
|
||||
doubled: boolean;
|
||||
isMWOpen: boolean;
|
||||
peaks?: boolean;
|
||||
oscStyle?: string;
|
||||
bandwidth?: string;
|
||||
coloring?: string;
|
||||
};
|
||||
|
||||
export function createVisualizerEngine(cfg: VEConfig) {
|
||||
const { canvas, analyser, colors, coloring, smallVis } = cfg;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
|
||||
// minimal reusable buffers (typed)
|
||||
const freqBuf = new Uint8Array(512);
|
||||
const timeBuf = new Uint8Array(1024);
|
||||
|
||||
// constants adapted from script.js
|
||||
const VIS_WIDTH = 75;
|
||||
const VIS_HEIGHT = 15;
|
||||
const TOTAL_VIS_SIZE = VIS_WIDTH * 2;
|
||||
const CANVAS_VIS_WIDTH = VIS_WIDTH + 1;
|
||||
const CANVAS_VIS_HEIGHT = VIS_HEIGHT + 1;
|
||||
|
||||
const WINDOWSHADE_WIDTH = CANVAS_VIS_WIDTH / 2;
|
||||
const WINDOWSHADE_HEIGHT = CANVAS_VIS_HEIGHT / 4;
|
||||
|
||||
const WINDOWSHADE_DOUBLESIZE_WIDTH = VIS_WIDTH;
|
||||
const WINDOWSHADE_DOUBLESIZE_HEIGHT = CANVAS_VIS_HEIGHT / 2 + 1;
|
||||
|
||||
const sapeaks = new Int32Array(VIS_WIDTH);
|
||||
const safalloff = new Float32Array(VIS_WIDTH);
|
||||
const sadata2 = new Float32Array(VIS_WIDTH);
|
||||
const sample = new Float32Array(512);
|
||||
|
||||
const inWaveData = new Float32Array(1024);
|
||||
const outSpectralData = new Float32Array(512);
|
||||
|
||||
const INITIAL_KICK_OFF = 3.0;
|
||||
|
||||
let doubleSized = !!cfg.doubled;
|
||||
let isMWOpen = !!cfg.isMWOpen;
|
||||
|
||||
const visMode = (() => {
|
||||
if (cfg.mode === "oscilloscope") return 1;
|
||||
if (cfg.mode === "bars") return 0;
|
||||
return -1;
|
||||
})();
|
||||
|
||||
const visOscStyle = (() => {
|
||||
if (cfg.oscStyle === "lines") return 1;
|
||||
if (cfg.oscStyle === "dots") return 0;
|
||||
if (cfg.oscStyle === "solid") return 2;
|
||||
return 0;
|
||||
})();
|
||||
|
||||
const saColorMode = (() => {
|
||||
if (coloring === "normal") return 0;
|
||||
if (coloring === "fire") return 1;
|
||||
return 2;
|
||||
})();
|
||||
let windowShaded = smallVis;
|
||||
const peaks = !!cfg.peaks;
|
||||
const wideBars = cfg.bandwidth === "wide" ? 1 : 0;
|
||||
|
||||
const maxFreqIndex = 512;
|
||||
const logMaxFreqIndex = Math.log10(maxFreqIndex);
|
||||
const logMinFreqIndex = 0;
|
||||
|
||||
const barFalloff = new Array(3, 6, 12, 16, 32);
|
||||
const peakFalloff = new Array(1.05, 1.1, 1.2, 1.4, 1.6);
|
||||
|
||||
const bFoSpeed = 2;
|
||||
const pFoSpeed = 1;
|
||||
|
||||
// ImageData framebuffer used by setPixel/vis
|
||||
// current canvas buffer size (may vary based on mode/windowShaded/doubleSized)
|
||||
let canvasBufWidth = CANVAS_VIS_WIDTH;
|
||||
let canvasBufHeight = CANVAS_VIS_HEIGHT;
|
||||
|
||||
function computeBufferSize() {
|
||||
if (windowShaded) {
|
||||
if (doubleSized) {
|
||||
return {
|
||||
w: WINDOWSHADE_DOUBLESIZE_WIDTH,
|
||||
h: WINDOWSHADE_DOUBLESIZE_HEIGHT + 1,
|
||||
};
|
||||
}
|
||||
return { w: WINDOWSHADE_WIDTH, h: WINDOWSHADE_HEIGHT + 1 };
|
||||
}
|
||||
return { w: CANVAS_VIS_WIDTH, h: CANVAS_VIS_HEIGHT };
|
||||
}
|
||||
|
||||
function drawColumn(
|
||||
imageData: ImageData,
|
||||
x: number,
|
||||
y1: number,
|
||||
y2: number,
|
||||
adjust: number,
|
||||
color: number
|
||||
): void {
|
||||
const lo = Math.min(y1, y2);
|
||||
const hi = Math.max(y1, y2);
|
||||
for (let y = lo; y <= hi; y++) {
|
||||
setPixel(imageData, x, y + adjust, color);
|
||||
}
|
||||
}
|
||||
|
||||
function drawBarFlat(
|
||||
imageData: ImageData,
|
||||
x: number,
|
||||
height: number,
|
||||
color: number,
|
||||
adjust: number
|
||||
): void {
|
||||
for (let i = 0; i < height; i++) {
|
||||
setPixel(imageData, x, i + adjust, color);
|
||||
}
|
||||
}
|
||||
|
||||
function drawBarGradient(
|
||||
imageData: ImageData,
|
||||
x: number,
|
||||
height: number,
|
||||
baseColor: number,
|
||||
adjust: number
|
||||
): void {
|
||||
let fall = 0;
|
||||
for (let i = 0; i < height; i++) {
|
||||
const shade = itru(baseColor - fall / 15);
|
||||
setPixel(imageData, x, i + adjust, shade);
|
||||
fall += 15;
|
||||
}
|
||||
}
|
||||
|
||||
const fft = new FFT();
|
||||
|
||||
// initialize image buffer with the computed size
|
||||
(() => {
|
||||
const sz = computeBufferSize();
|
||||
canvasBufWidth = sz.w;
|
||||
canvasBufHeight = sz.h;
|
||||
})();
|
||||
let myImageData = ctx.createImageData(canvasBufWidth, canvasBufHeight);
|
||||
|
||||
// Build RGBA palette from cfg.colors (CSS color strings).
|
||||
// Uses an offscreen 1x1 canvas to resolve color strings into RGBA.
|
||||
const colorProbe = document.createElement("canvas");
|
||||
colorProbe.width = 1;
|
||||
colorProbe.height = 1;
|
||||
const colorProbeCtx = colorProbe.getContext("2d")!;
|
||||
const paletteRGBA: number[][] =
|
||||
colors && colors.length
|
||||
? colors.map((c) => {
|
||||
try {
|
||||
colorProbeCtx.clearRect(0, 0, 1, 1);
|
||||
colorProbeCtx.fillStyle = c;
|
||||
colorProbeCtx.fillRect(0, 0, 1, 1);
|
||||
const d = colorProbeCtx.getImageData(0, 0, 1, 1).data;
|
||||
return [d[0], d[1], d[2], d[3]];
|
||||
} catch {
|
||||
return [0, 0, 0, 255];
|
||||
}
|
||||
})
|
||||
: [[0, 0, 0, 255]];
|
||||
|
||||
/**
|
||||
* Feeds audio data to the FFT.
|
||||
* @param analyserNode The AnalyserNode used to get the audio data.
|
||||
* @param fftInstance The FFTNullsoft instance from the PaintHandler.
|
||||
*/
|
||||
function processFFT(
|
||||
analyserNode: AnalyserNode,
|
||||
fftInstance: FFT,
|
||||
waveData: Float32Array,
|
||||
spectralData: Float32Array
|
||||
): void {
|
||||
const dataArray = new Uint8Array(1024);
|
||||
|
||||
analyserNode.getByteTimeDomainData(dataArray);
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
waveData[i] = (dataArray[i] - 128) / 24; // is 24 arbitary? yes.
|
||||
// i could just make this a constant and call it something stupid like
|
||||
// const EYED_VOLUME_LEVEL_FOR_FFT_TO_MORE_OR_LESS_MATCH_WINAMP_IN_TERMS_OF_LOUDNESS = 24;
|
||||
// but that would be silly...
|
||||
}
|
||||
fftInstance.timeToFrequencyDomain(waveData, spectralData);
|
||||
// This is to roughly emulate the Analyzer in more modern versions of Winamp.
|
||||
// 2.x and early 5.x versions had a completely linear(?) FFT, if so desired the
|
||||
// scale variable can be set to 0.0
|
||||
|
||||
// This factor controls the scaling from linear to logarithmic.
|
||||
// scale = 0.0 -> fully linear scaling
|
||||
// scale = 1.0 -> fully logarithmic scaling
|
||||
const scale = 0.91; // Adjust this value between 0.0 and 1.0
|
||||
for (let x = 0; x < analyserNode.frequencyBinCount; x++) {
|
||||
// Linear interpolation between linear and log scaling
|
||||
const linearIndex =
|
||||
(x / (analyserNode.frequencyBinCount - 1)) * (maxFreqIndex - 1);
|
||||
const logScaledIndex =
|
||||
logMinFreqIndex +
|
||||
((logMaxFreqIndex - logMinFreqIndex) * x) /
|
||||
(analyserNode.frequencyBinCount - 1);
|
||||
const logIndex = Math.pow(10, logScaledIndex);
|
||||
|
||||
// Interpolating between linear and logarithmic scaling
|
||||
const scaledIndex = (1.0 - scale) * linearIndex + scale * logIndex;
|
||||
|
||||
let index1 = Math.floor(scaledIndex);
|
||||
let index2 = Math.ceil(scaledIndex);
|
||||
|
||||
if (index1 >= maxFreqIndex) {
|
||||
index1 = maxFreqIndex - 1;
|
||||
} else if (index2 >= maxFreqIndex) {
|
||||
index2 = maxFreqIndex - 1;
|
||||
}
|
||||
|
||||
if (index1 === index2) {
|
||||
sample[x] = spectralData[index1];
|
||||
} else {
|
||||
const frac2 = scaledIndex - index1;
|
||||
const frac1 = 1.0 - frac2;
|
||||
sample[x] = frac1 * spectralData[index1] + frac2 * spectralData[index2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function prepare() {
|
||||
// called when layout changes — reconfigure ctx and any caches here.
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
// recompute buffer size for current mode and recreate image buffer
|
||||
const sz = computeBufferSize();
|
||||
canvasBufWidth = sz.w;
|
||||
canvasBufHeight = sz.h;
|
||||
myImageData = ctx.createImageData(canvasBufWidth, canvasBufHeight);
|
||||
}
|
||||
|
||||
function itru(n: number) {
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
function setPixel(img: ImageData, x: number, y: number, c: number) {
|
||||
// flip canvas
|
||||
|
||||
// ❤️ Don't flip the canvas.
|
||||
// ...
|
||||
// Fine. You want to see
|
||||
// what happens so bad?
|
||||
// Watch what happens when
|
||||
// I don't flip the canvas!
|
||||
// const fy = y; <-- ❤️ Proceed.
|
||||
const fy = canvasBufHeight - 1 - y;
|
||||
|
||||
// palette lookup
|
||||
// if we exceed the bounds
|
||||
// just paint the first index in the array
|
||||
const p = paletteRGBA[c] || paletteRGBA[0];
|
||||
|
||||
// "Let's get crazy." - Bob Ross
|
||||
const idx = (fy * canvasBufWidth + x) * 4;
|
||||
img.data[idx + 0] = p[0];
|
||||
img.data[idx + 1] = p[1];
|
||||
img.data[idx + 2] = p[2];
|
||||
img.data[idx + 3] = p[3];
|
||||
}
|
||||
|
||||
// a simple audio gatherer
|
||||
// is kind of an equivalent of winamp's SAAddPCMData function for input plugins
|
||||
// except we aren't being fed samples from plugins
|
||||
// and just take the preprocessed data from the browser
|
||||
function getSample(): number[] {
|
||||
// holds both squashed spectrum and oscilloscope data
|
||||
const arr = new Array(TOTAL_VIS_SIZE);
|
||||
if (!analyser) return arr;
|
||||
|
||||
analyser.getByteTimeDomainData(timeBuf);
|
||||
analyser.getByteFrequencyData(freqBuf);
|
||||
|
||||
// it is impossible to implement a proper 576 sample buffer
|
||||
// with the web audio api's sliding window buffer
|
||||
// so this is the next best thing
|
||||
|
||||
// the reasoning for 576 is the following:
|
||||
// (the question was about why winamp DSP plugins
|
||||
// can return 576/1152 samples)
|
||||
// it was linked into the mp3 decoding originally
|
||||
// which produces 1152/576 samples depending
|
||||
// on whether it was MPEG-1 or MPEG-2.
|
||||
const dataArray576 = timeBuf.slice(0, Math.min(576, timeBuf.length));
|
||||
|
||||
processFFT(analyser, fft, inWaveData, outSpectralData);
|
||||
|
||||
// fill 0..74 with FFT data
|
||||
for (let x = 0; x < VIS_WIDTH; x++) {
|
||||
// squash down the 512 long FFT buffer into a 75 short buffer
|
||||
// preserves all frequency details regardless
|
||||
const idxSpec = Math.floor((x / VIS_WIDTH) * sample.length);
|
||||
arr[x] = itru(sample[idxSpec]);
|
||||
}
|
||||
|
||||
// fill 75..149 with oscilloscope data
|
||||
for (let x = 0; x < VIS_WIDTH; x++) {
|
||||
// do the same for the oscilloscope buffer
|
||||
// but shift the destination by 75 indices to not overwrite the FFT
|
||||
|
||||
// getByteTimeDomainData's center point is shifted from 128 to 0
|
||||
// just so we don't have to do any other nasty re-biasing
|
||||
// in the actual visualizer function
|
||||
const idxTD = Math.trunc((x / VIS_WIDTH) * dataArray576.length);
|
||||
arr[x + VIS_WIDTH] = Math.round((dataArray576[idxTD] - 128) / 8);
|
||||
// the data is then finally divided by 8
|
||||
// just so it ranges from -32..32
|
||||
}
|
||||
|
||||
if (visMode === 0) {
|
||||
for (let i = 0; i < VIS_WIDTH; i++) {
|
||||
arr[i + VIS_WIDTH] = 0;
|
||||
}
|
||||
} else if (visMode === 1) {
|
||||
for (let i = 0; i < VIS_WIDTH; i++) {
|
||||
arr[i] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
function vis(samples: number[]) {
|
||||
// visAdjust simulates the behavior of the scope and analyzer
|
||||
// being pushed "down" by 2 pixels whenever we are not in doublesize mode
|
||||
// technically this isn't accurate to the decompilation effort
|
||||
// however i found it extremely overkill to then also handle painting
|
||||
// doublesize mode and non-doublesize mode in separate conditions
|
||||
// no ambiguity: use normalized booleans
|
||||
// if main window is closed => always shifted (-2).
|
||||
// when main window is open, doubled controls whether to remove the shift.
|
||||
const visAdjust = isMWOpen && doubleSized ? 0 : -2;
|
||||
|
||||
// going forward, setPixel handles plotting the pixels to our framebuffer
|
||||
// at the specified x and y coordinates, but also handles the way the visualizer
|
||||
// works by selecting colors, as from what i could tell, the visualizer
|
||||
// uses indexed RGB, and viscolor.txt is our palette to paint it in
|
||||
// all sorts of beautiful colors, that is the 4th parameter
|
||||
if (windowShaded) {
|
||||
for (let x = 0; x < VIS_WIDTH + 1; x++) {
|
||||
for (let y = 0; y < VIS_HEIGHT + 1; y++) {
|
||||
if (x % 2 === 1 || y % 2 === 1) {
|
||||
setPixel(myImageData, x, y, 0);
|
||||
} else {
|
||||
setPixel(myImageData, x, y, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let x = 0; x < VIS_WIDTH + 1; x++) {
|
||||
for (let y = 0; y < VIS_HEIGHT + 1; y++) {
|
||||
if (x % 2 === 1 || y % 2 === 1) {
|
||||
setPixel(myImageData, x, y, 0);
|
||||
} else {
|
||||
setPixel(myImageData, x, y, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!windowShaded) {
|
||||
if (visMode === 1) {
|
||||
// from Winamp 2.63, decompiled from Ghidra, cleaned up and ported with GPT-5.1
|
||||
let prevV = -1;
|
||||
|
||||
for (let x = 0; x < VIS_WIDTH; x++) {
|
||||
let v = samples[x + VIS_WIDTH] + 8;
|
||||
|
||||
v = Math.max(0, Math.min(VIS_HEIGHT, v));
|
||||
|
||||
const color = Math.abs(itru(v / 2 - 4)) + 18;
|
||||
|
||||
switch (visOscStyle) {
|
||||
case 0:
|
||||
setPixel(myImageData, x, v + visAdjust, color);
|
||||
continue;
|
||||
|
||||
case 2: {
|
||||
const anchor = v < 8 ? 7 : 8;
|
||||
drawColumn(myImageData, x, v, anchor, visAdjust, color);
|
||||
continue;
|
||||
}
|
||||
|
||||
case 1:
|
||||
break;
|
||||
}
|
||||
|
||||
if (prevV === -1) {
|
||||
prevV = v;
|
||||
}
|
||||
|
||||
if (v < prevV) {
|
||||
drawColumn(myImageData, x, v, prevV - 1, visAdjust, color);
|
||||
} else {
|
||||
drawColumn(myImageData, x, v, prevV, visAdjust, color);
|
||||
}
|
||||
|
||||
prevV = v;
|
||||
}
|
||||
} else if (visMode === 0) {
|
||||
let chunker, chunkedData;
|
||||
for (let x = 0; x < VIS_WIDTH; x++) {
|
||||
if (wideBars) {
|
||||
// when wideBars is enabled, each bar represents a 4-sample chunk
|
||||
// i = x & ~3 clears the lowest 2 bits of x (bitwise AND with 11111100)
|
||||
// effectively rounding x down to the nearest multiple of 4:
|
||||
// 0-0, 1-0, 2-0, 3-0, 4-4, 5-4, ...
|
||||
chunker = x & ~3;
|
||||
|
||||
// get the chunks for this iteration and average them, then divide by 4
|
||||
chunkedData =
|
||||
(samples[chunker] +
|
||||
samples[chunker + 1] +
|
||||
samples[chunker + 2] +
|
||||
samples[chunker + 3]) >>
|
||||
2;
|
||||
} else {
|
||||
// just take the original array and leave
|
||||
chunkedData = itru(samples[x]);
|
||||
}
|
||||
|
||||
if (chunkedData >= VIS_HEIGHT) {
|
||||
chunkedData = VIS_HEIGHT;
|
||||
}
|
||||
|
||||
// barFalloff is the array that holds 5 values
|
||||
// these values determine how fast the analyzer should fall per tick
|
||||
// dividing the value by 16.0f ensures that it doesn't fall super fast
|
||||
// so it isnt that reactive to change
|
||||
safalloff[x] -= barFalloff[bFoSpeed] / 16.0;
|
||||
|
||||
// ensure that we're ALWAYS above safalloff
|
||||
if (safalloff[x] <= chunkedData) {
|
||||
safalloff[x] = chunkedData;
|
||||
}
|
||||
|
||||
// peak detection:
|
||||
// convert falloff to 0..4095 domain and compare to stored peak
|
||||
// when falloff exceeds the peak, update peak position
|
||||
if (sapeaks[x] <= itru(safalloff[x] * 256)) {
|
||||
sapeaks[x] = safalloff[x] * 256;
|
||||
sadata2[x] = INITIAL_KICK_OFF;
|
||||
}
|
||||
|
||||
// saColorMode:
|
||||
// 1: normal spectrum gradient (low > dark, high > bright)
|
||||
// 2: inverted gradient (low > bright, high > dark)
|
||||
// else: flat base color at index 17
|
||||
let px;
|
||||
const level = itru(safalloff[x]);
|
||||
|
||||
if (saColorMode === 1) px = level + 2;
|
||||
else if (saColorMode === 2) px = 17 - level;
|
||||
else px = 17;
|
||||
|
||||
const roundedBar = itru(level);
|
||||
const roundedPeak = itru(sapeaks[x] / 256);
|
||||
|
||||
// skip drawing the 4th column of each 4-sample block when wideBars === 1
|
||||
// (x & 3) === 3 means x % 4 === 3 → the last column in a block of 4
|
||||
// otherwise, if wideBars not true, give us everything
|
||||
if (wideBars !== 1 || (x & 3) !== 3) {
|
||||
if (saColorMode === 2) {
|
||||
drawBarFlat(myImageData, x, roundedBar, px, visAdjust);
|
||||
} else if (roundedBar > 0) {
|
||||
// non-inverted modes: draw gradient shading downward from px
|
||||
drawBarGradient(myImageData, x, roundedBar, px, visAdjust);
|
||||
}
|
||||
|
||||
// 23 is the index in our palette which defines what color the peaks should be
|
||||
if (peaks && roundedPeak > 0 && roundedPeak < 16) {
|
||||
setPixel(myImageData, x, roundedPeak + visAdjust, 23);
|
||||
}
|
||||
}
|
||||
|
||||
// peak falloff handling:
|
||||
// decrease stored peak by the current peak falloff speed
|
||||
sapeaks[x] -= itru(sadata2[x]);
|
||||
|
||||
// decay the peak falloff speed itself using peakFalloff
|
||||
sadata2[x] *= peakFalloff[pFoSpeed];
|
||||
if (sapeaks[x] <= 0) {
|
||||
sapeaks[x] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (doubleSized) {
|
||||
if (visMode === 0) {
|
||||
let chunker, chunkedData;
|
||||
for (let x = 0; x < VIS_WIDTH; x++) {
|
||||
if (wideBars) {
|
||||
chunker = x & ~3;
|
||||
chunkedData =
|
||||
(samples[chunker] +
|
||||
samples[chunker + 1] +
|
||||
samples[chunker + 2] +
|
||||
samples[chunker + 3]) >>
|
||||
2;
|
||||
} else {
|
||||
chunkedData = samples[x];
|
||||
}
|
||||
|
||||
if (chunkedData >= VIS_HEIGHT) {
|
||||
chunkedData = VIS_HEIGHT;
|
||||
}
|
||||
|
||||
safalloff[x] -= barFalloff[bFoSpeed] / 16.0;
|
||||
|
||||
if (safalloff[x] <= chunkedData) {
|
||||
safalloff[x] = chunkedData;
|
||||
}
|
||||
|
||||
if (sapeaks[x] <= itru(safalloff[x] * 256)) {
|
||||
sapeaks[x] = safalloff[x] * 256;
|
||||
sadata2[x] = 3.0;
|
||||
}
|
||||
|
||||
let px;
|
||||
const level = itru(safalloff[x]);
|
||||
|
||||
if (saColorMode === 1) px = level + 2;
|
||||
else if (saColorMode === 2) px = 17 - level;
|
||||
else px = 17;
|
||||
|
||||
let roundedBar = itru((itru(level) * 10) / 15);
|
||||
if (roundedBar > 10) roundedBar = 10;
|
||||
|
||||
let roundedPeak = itru(((sapeaks[x] / 256) * 10) / 15);
|
||||
if (roundedPeak > 10) roundedPeak = 10;
|
||||
|
||||
if (wideBars !== 1 || (x & 3) !== 3) {
|
||||
if (saColorMode === 2) {
|
||||
for (let i = 0; i < roundedBar; i++) {
|
||||
setPixel(myImageData, x, i /* + 6*/, px);
|
||||
}
|
||||
} else if (roundedBar > 0) {
|
||||
let fall = 0;
|
||||
for (let i = 0; i < roundedBar; i++) {
|
||||
// fall / 10
|
||||
// gets us more accurate and precise representation of what should be happening
|
||||
const fallDiv10 = Number((BigInt(fall) * 0xcccccccdn) >> 35n);
|
||||
const shade = px - fallDiv10;
|
||||
setPixel(myImageData, x, i /* + 6*/, shade);
|
||||
fall += 15;
|
||||
}
|
||||
}
|
||||
|
||||
if (peaks && roundedPeak >= 0 && roundedPeak < 10) {
|
||||
setPixel(myImageData, x, roundedPeak /* + 6*/, 23);
|
||||
}
|
||||
}
|
||||
|
||||
sapeaks[x] -= itru(sadata2[x]);
|
||||
sadata2[x] *= peakFalloff[pFoSpeed];
|
||||
if (sapeaks[x] <= 0) {
|
||||
sapeaks[x] = 0;
|
||||
}
|
||||
}
|
||||
} else if (visMode === 1) {
|
||||
let prevV = -5;
|
||||
for (let x = 0; x < WINDOWSHADE_DOUBLESIZE_WIDTH; x++) {
|
||||
let v =
|
||||
(samples[x + WINDOWSHADE_DOUBLESIZE_WIDTH] + 8) *
|
||||
(WINDOWSHADE_DOUBLESIZE_HEIGHT + 1);
|
||||
// shifts v right by 31 bits to extract the sign bit (0 if positive, -1 if negative)
|
||||
// "& 15" converts the sign bit into either 0 (for positive v) or 15 (for negative v)
|
||||
// adds this value to v before the final shift
|
||||
// final ">> 4" divides by 16, but with correction for negative values
|
||||
//
|
||||
// net effect: arithmetic division by 16 that rounds negative values toward zero
|
||||
v = (v + ((v >> 31) & 0x0f)) >> 4;
|
||||
v = Math.max(0, Math.min(WINDOWSHADE_DOUBLESIZE_HEIGHT, v));
|
||||
|
||||
// this is technically a bug, since there is no reason to see if prevV is -5
|
||||
// for accuracies sake however, this is preserved and will not be fixed
|
||||
if (visOscStyle === 0 || prevV === -5) {
|
||||
setPixel(myImageData, x, v /* + 6*/, 18);
|
||||
prevV = v;
|
||||
} else if (visOscStyle === 1) {
|
||||
const diff = v - prevV;
|
||||
let count = (diff < 0 ? -diff : diff) + 1;
|
||||
|
||||
let yy = v;
|
||||
|
||||
if (diff < 0) {
|
||||
// going DOWN
|
||||
while (count--) {
|
||||
setPixel(myImageData, x, yy /* + 6*/, 18);
|
||||
yy++; // move downward
|
||||
}
|
||||
} else {
|
||||
// going UP
|
||||
while (count--) {
|
||||
setPixel(myImageData, x, yy /* + 6*/, 18);
|
||||
yy--; // move upward
|
||||
}
|
||||
}
|
||||
|
||||
prevV = v;
|
||||
} else if (visOscStyle === 2) {
|
||||
if (v < 4) {
|
||||
let h = 5 - v;
|
||||
let yy = v;
|
||||
while (h !== 0) {
|
||||
setPixel(myImageData, x, yy /* + 6*/, 18);
|
||||
yy++;
|
||||
h--;
|
||||
}
|
||||
} else {
|
||||
let h = v - 3;
|
||||
let yy = v;
|
||||
while (h !== 0) {
|
||||
setPixel(myImageData, x, yy /* + 6*/, 18);
|
||||
yy--;
|
||||
h--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (visMode === 0) {
|
||||
let chunker,
|
||||
chunkedData = 0;
|
||||
// unsure why 38 - 1 was being done here
|
||||
// technically a bug, won't fix
|
||||
for (let x = 0; x < WINDOWSHADE_WIDTH - 1; x++) {
|
||||
if (wideBars) {
|
||||
chunker = (x & ~3) * 2;
|
||||
chunkedData =
|
||||
(samples[chunker] +
|
||||
samples[chunker + 1] +
|
||||
samples[chunker + 2] +
|
||||
samples[chunker + 3]) >>
|
||||
2;
|
||||
} else {
|
||||
chunkedData =
|
||||
itru(itru(samples[x * 2]) + itru(samples[x * 2 + 1])) / 2;
|
||||
}
|
||||
if (chunkedData >= 15) {
|
||||
chunkedData = 15;
|
||||
}
|
||||
|
||||
safalloff[x * 2] -= barFalloff[bFoSpeed] / 16.0;
|
||||
|
||||
if (safalloff[x * 2] <= chunkedData) {
|
||||
safalloff[x * 2] = chunkedData;
|
||||
}
|
||||
|
||||
if (sapeaks[x * 2] <= itru(safalloff[x * 2] * 256)) {
|
||||
sapeaks[x * 2] = safalloff[x * 2] * 256;
|
||||
sadata2[x * 2] = 3.0;
|
||||
}
|
||||
|
||||
let px;
|
||||
const level = itru(safalloff[x * 2]);
|
||||
|
||||
if (saColorMode === 1) px = level + 2;
|
||||
else if (saColorMode === 2) px = 17 - level;
|
||||
else px = 17;
|
||||
|
||||
let roundedBar = itru(
|
||||
(itru(level) * (WINDOWSHADE_HEIGHT + 1)) / VIS_HEIGHT
|
||||
);
|
||||
if (roundedBar > WINDOWSHADE_HEIGHT + 1)
|
||||
roundedBar = WINDOWSHADE_HEIGHT + 1;
|
||||
|
||||
let roundedPeak = itru(
|
||||
((sapeaks[x * 2] / 256) * (WINDOWSHADE_HEIGHT + 1)) / VIS_HEIGHT
|
||||
);
|
||||
if (roundedPeak > WINDOWSHADE_HEIGHT + 1)
|
||||
roundedPeak = WINDOWSHADE_HEIGHT + 1;
|
||||
|
||||
if (wideBars !== 1 || (x & 3) !== 3) {
|
||||
if (saColorMode === 2) {
|
||||
for (let i = 0; i < roundedBar; i++) {
|
||||
setPixel(myImageData, x, i /* + 11 */, px);
|
||||
}
|
||||
} else if (roundedBar > 0) {
|
||||
let fall = 0;
|
||||
for (let i = 0; i < roundedBar; i++) {
|
||||
const shade = itru(px - fall / (WINDOWSHADE_HEIGHT + 1));
|
||||
setPixel(myImageData, x, i /* + 11 */, shade);
|
||||
fall += 15;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
peaks &&
|
||||
roundedPeak >= 0 &&
|
||||
roundedPeak < WINDOWSHADE_HEIGHT + 1
|
||||
) {
|
||||
setPixel(myImageData, x, roundedPeak /* + 11 */, 23);
|
||||
}
|
||||
}
|
||||
|
||||
sapeaks[x * 2] -= itru(sadata2[x * 2]);
|
||||
sadata2[x * 2] *= peakFalloff[pFoSpeed];
|
||||
if (sapeaks[x * 2] <= 0) {
|
||||
sapeaks[x * 2] = 0;
|
||||
}
|
||||
}
|
||||
} else if (visMode === 1) {
|
||||
let prevV = -5;
|
||||
for (let x = 0; x < WINDOWSHADE_WIDTH; x++) {
|
||||
let v = (samples[x + VIS_WIDTH] + 8) * (WINDOWSHADE_HEIGHT + 1);
|
||||
v = (v + ((v >> 31) & 15)) >> 4;
|
||||
v = Math.max(0, Math.min(WINDOWSHADE_HEIGHT, v));
|
||||
|
||||
if (visOscStyle === 0 || prevV === -5) {
|
||||
setPixel(myImageData, x, v /* + 11 */, 18);
|
||||
prevV = v;
|
||||
} else if (visOscStyle === 1) {
|
||||
const diff = v - prevV;
|
||||
let count = (diff < 0 ? -diff : diff) + 1;
|
||||
|
||||
let yy = v;
|
||||
|
||||
if (diff < 0) {
|
||||
// going DOWN
|
||||
while (count--) {
|
||||
setPixel(myImageData, x, yy /* + 11 */, 18);
|
||||
yy++; // move downward
|
||||
}
|
||||
} else {
|
||||
// going UP
|
||||
while (count--) {
|
||||
setPixel(myImageData, x, yy /* + 11 */, 18);
|
||||
yy--; // move upward
|
||||
}
|
||||
}
|
||||
|
||||
prevV = v;
|
||||
} else if (visOscStyle === 2) {
|
||||
if (v < 2) {
|
||||
let h = 3 - v;
|
||||
let yy = v;
|
||||
while (h !== 0) {
|
||||
setPixel(myImageData, x, yy /* + 11 */, 18);
|
||||
yy++;
|
||||
h--;
|
||||
}
|
||||
} else {
|
||||
let h = v - 1;
|
||||
let yy = v;
|
||||
while (h !== 0) {
|
||||
setPixel(myImageData, x, yy /* + 11 */, 18);
|
||||
yy--;
|
||||
h--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function paintFrame() {
|
||||
if (!ctx) return;
|
||||
if (!analyser || cfg.mode === "none") {
|
||||
// nothing to draw — caller may clear
|
||||
return;
|
||||
}
|
||||
|
||||
// gather samples, render into image buffer, and blit
|
||||
vis(getSample());
|
||||
ctx.putImageData(myImageData, 0, 0);
|
||||
}
|
||||
|
||||
function updateConfig(newCfg: Partial<VEConfig>) {
|
||||
// Update mutable configuration values that can change without recreating the painter
|
||||
if (newCfg.doubled !== undefined) {
|
||||
doubleSized = !!newCfg.doubled;
|
||||
prepare(); // recalculate buffer size
|
||||
}
|
||||
if (newCfg.isMWOpen !== undefined) {
|
||||
isMWOpen = !!newCfg.isMWOpen;
|
||||
prepare(); // recalculate buffer size
|
||||
}
|
||||
if (newCfg.smallVis !== undefined) {
|
||||
windowShaded = !!newCfg.smallVis;
|
||||
prepare(); // recalculate buffer size
|
||||
}
|
||||
}
|
||||
|
||||
return { prepare, paintFrame, updateConfig };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue