This commit is contained in:
Eris Lund 2026-07-08 14:01:33 +02:00 committed by GitHub
commit b46e449637
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 830 additions and 89 deletions

View file

@ -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;

View 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 };
}