diff --git a/js/components/Visualizer.js b/js/components/Visualizer.js index be1e4f34..d8e4f2c0 100644 --- a/js/components/Visualizer.js +++ b/js/components/Visualizer.js @@ -6,7 +6,8 @@ import { getWindowShade, getVisualizerStyle } from "../selectors"; import { VISUALIZERS } from "../constants"; const PIXEL_DENSITY = 2; -const BAR_WIDTH = 6; +const NUM_BARS = 20; +const BAR_PEAK_DROP_RATE = 0.01; const GRADIENT_COLOR_COUNT = 16; const PEAK_COLOR_INDEX = 23; @@ -23,6 +24,8 @@ function sliceAverage(dataArray, sliceWidth, sliceNumber) { class Visualizer extends React.Component { componentDidMount() { + this.barPeaks = new Array(NUM_BARS).fill(0); + this.barPeakFrames = new Array(NUM_BARS).fill(0); this.canvasCtx = this.canvas.getContext("2d"); this.canvasCtx.imageSmoothingEnabled = false; @@ -72,6 +75,38 @@ class Visualizer extends React.Component { return this.props.width * PIXEL_DENSITY; } + _barWidth() { + const barWidth = Math.floor(this._width() / NUM_BARS); + if (barWidth % 2 === 0) { + return barWidth; + } + + return barWidth - 1; + } + + _generateOctaveBuckets() { + const octaveBuckets = new Array(NUM_BARS).fill(0); + const minHz = 200; + const maxHz = 22050; + const octaveStep = Math.pow(maxHz / minHz, 1 / NUM_BARS); + + octaveBuckets[0] = 0; + octaveBuckets[1] = minHz; + for (let i = 2; i < NUM_BARS - 1; i++) { + octaveBuckets[i] = octaveBuckets[i - 1] * octaveStep; + } + octaveBuckets[NUM_BARS - 1] = maxHz; + + for (let i = 0; i < NUM_BARS; i++) { + const octaveIdx = Math.floor( + (octaveBuckets[i] / maxHz) * this.bufferLength + ); + octaveBuckets[i] = octaveIdx; + } + + return octaveBuckets; + } + setStyle() { if (!this.props.colors) { return; @@ -80,15 +115,17 @@ class Visualizer extends React.Component { // update. this.preRenderBg(); this.preRenderBar(); + this.props.analyser.fftSize = 2048; if (this.props.style === VISUALIZERS.OSCILLOSCOPE) { - this.props.analyser.fftSize = 1024; this.bufferLength = this.props.analyser.fftSize; this.dataArray = new Uint8Array(this.bufferLength); } else if (this.props.style === VISUALIZERS.BAR) { - this.props.analyser.fftSize = 64; // Must be a power of two - // Number of bins/bars we get this.bufferLength = this.props.analyser.frequencyBinCount; this.dataArray = new Uint8Array(this.bufferLength); + + if (!this.octaveBuckets) { + this.octaveBuckets = this._generateOctaveBuckets(); + } } } @@ -127,8 +164,9 @@ class Visualizer extends React.Component { */ // Off-screen canvas for pre-rendering a single bar gradient + const barWidth = this._barWidth(); this.barCanvas = document.createElement("canvas"); - this.barCanvas.width = BAR_WIDTH; + this.barCanvas.width = barWidth; this.barCanvas.height = this._height(); const offset = 2; // The first two colors are for the background; @@ -151,7 +189,7 @@ class Visualizer extends React.Component { const colorIndex = GRADIENT_COLOR_COUNT - 1 - Math.floor(i * multiplier); barCanvasCtx.fillStyle = gradientColors[colorIndex]; const y = this._height() - i * PIXEL_DENSITY; - barCanvasCtx.fillRect(0, y, BAR_WIDTH, PIXEL_DENSITY); + barCanvasCtx.fillRect(0, y, barWidth, PIXEL_DENSITY); } } @@ -204,31 +242,56 @@ class Visualizer extends React.Component { this.canvasCtx.stroke(); } - _printBar(x, height) { + _printBar(x, height, peakHeight) { height = Math.ceil(height) * PIXEL_DENSITY; - if (height > 0) { + peakHeight = Math.ceil(peakHeight) * PIXEL_DENSITY; + if (height > 0 || peakHeight > 0) { const y = this._height() - height; const ctx = this.canvasCtx; // Draw the gradient - const b = BAR_WIDTH; - ctx.drawImage(this.barCanvas, 0, y, b, height, x, y, b, height); + const b = this._barWidth(); + if (height > 0) { + ctx.drawImage(this.barCanvas, 0, y, b, height, x, y, b, height); + } // Draw the gray peak line - // TODO: Rather than sitting on top of the bar, these - // are expected to be behind the top pixel, and fall more slowly. - // Currently these overwrite the top pixel. + const peakY = this._height() - peakHeight; ctx.fillStyle = this.props.colors[PEAK_COLOR_INDEX]; - ctx.fillRect(x, y, BAR_WIDTH, PIXEL_DENSITY); + ctx.fillRect(x, peakY, b, PIXEL_DENSITY); } } _paintBarFrame() { this.props.analyser.getByteFrequencyData(this.dataArray); - // We are printing bars off the right of the canvas :( - const xOffset = BAR_WIDTH + PIXEL_DENSITY; // Bar width, plus a pixel of spacing to the right. const heightMultiplier = this._renderHeight() / 256; - for (let j = 0; j < this.bufferLength; j++) { - this._printBar(j * xOffset, this.dataArray[j] * heightMultiplier); + const barWidth = this._barWidth(); + const xOffset = barWidth + PIXEL_DENSITY; // Bar width, plus a pixel of spacing to the right. + for (let j = 0; j < NUM_BARS - 1; j++) { + const start = this.octaveBuckets[j]; + const end = this.octaveBuckets[j + 1]; + let amplitude = 0; + for (let k = start; k < end; k++) { + amplitude += this.dataArray[k]; + } + amplitude /= end - start; + + // The drop rate should probably be normalized to the rendering FPS, for now assume 60 FPS + let barPeak = + this.barPeaks[j] - + BAR_PEAK_DROP_RATE * Math.pow(this.barPeakFrames[j], 2); + if (barPeak < amplitude) { + barPeak = amplitude; + this.barPeakFrames[j] = 0; + } else { + this.barPeakFrames[j] += 1; + } + this.barPeaks[j] = barPeak; + + this._printBar( + j * xOffset, + amplitude * heightMultiplier, + barPeak * heightMultiplier + ); } } diff --git a/js/media/index.js b/js/media/index.js index 22e62866..3a61d0cd 100644 --- a/js/media/index.js +++ b/js/media/index.js @@ -55,11 +55,8 @@ export default class Media { // Create the analyser node for the visualizer this._analyser = this._context.createAnalyser(); this._analyser.fftSize = 2048; - // Tuned by @The1Freeman to resemble Winamp 2.9x. - // https://github.com/captbaritone/webamp/pull/613 - this._analyser.minDecibels = -64; - this._analyser.maxDecibels = 0; - this._analyser.smoothingTimeConstant = 0.25; + // don't smooth audio analysis + this._analyser.smoothingTimeConstant = 0.0; // Create the gain node for the volume control this._gainNode = this._context.createGain();