From d296a6d5a135f8047bb502f100a2684fb1ab29a8 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Wed, 8 Apr 2020 21:23:20 -0700 Subject: [PATCH] Split visualizer into smaller files --- js/components/Visualizer.tsx | 258 ++------------------- js/components/useBarVisualizer.ts | 186 +++++++++++++++ js/components/useOscilloscopeVisualizer.ts | 83 +++++++ 3 files changed, 293 insertions(+), 234 deletions(-) create mode 100644 js/components/useBarVisualizer.ts create mode 100644 js/components/useOscilloscopeVisualizer.ts diff --git a/js/components/Visualizer.tsx b/js/components/Visualizer.tsx index 007d90d8..03aaeeb5 100644 --- a/js/components/Visualizer.tsx +++ b/js/components/Visualizer.tsx @@ -3,55 +3,16 @@ import React, { useMemo, useCallback, useState, useLayoutEffect } from "react"; import * as Actions from "../actionCreators"; import * as Selectors from "../selectors"; import { useTypedSelector, useActionCreator } from "../hooks"; +import { usePaintOscilloscopeFrame } from "./useOscilloscopeVisualizer"; +import { usePaintBarFrame, usePaintBar } from "./useBarVisualizer"; import { VISUALIZERS, MEDIA_STATUS } from "../constants"; const PIXEL_DENSITY = 2; -const BAR_WIDTH = 3 * PIXEL_DENSITY; -const GRADIENT_COLOR_COUNT = 16; -const PEAK_COLOR_INDEX = 23; -const BAR_PEAK_DROP_RATE = 0.01; -const NUM_BARS = 20; type Props = { analyser: AnalyserNode; }; -function octaveBucketsForBufferLength(bufferLength: number): number[] { - 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) * bufferLength); - octaveBuckets[i] = octaveIdx; - } - - return octaveBuckets; -} - -// Return the average value in a slice of dataArray -function sliceAverage( - dataArray: Uint8Array, - sliceWidth: number, - sliceNumber: number -): number { - const start = sliceWidth * sliceNumber; - const end = start + sliceWidth; - let sum = 0; - for (let i = start; i < end; i++) { - sum += dataArray[i]; - } - return sum / sliceWidth; -} - // Pre-render the background grid function preRenderBg( width: number, @@ -83,54 +44,10 @@ function preRenderBg( return bgCanvas; } -function preRenderBar( - height: number, - colors: string[], - renderHeight: number -): HTMLCanvasElement { - /** - * The order of the colours is commented in the file: the fist two colours - * define the background and dots (check it to see what are the dots), the - * next 16 colours are the analyzer's colours from top to bottom, the next - * 5 colours are the oscilloscope's ones, from center to top/bottom, the - * last colour is for the analyzer's peak markers. - */ - - // Off-screen canvas for pre-rendering a single bar gradient - const barCanvas = document.createElement("canvas"); - barCanvas.width = BAR_WIDTH; - barCanvas.height = height; - - const offset = 2; // The first two colors are for the background; - const gradientColors = colors.slice(offset, offset + GRADIENT_COLOR_COUNT); - - const barCanvasCtx = barCanvas.getContext("2d"); - if (barCanvasCtx == null) { - throw new Error("Could not construct canvas context"); - } - const multiplier = GRADIENT_COLOR_COUNT / renderHeight; - // In shade mode, the five colors are, from top to bottom: - // 214, 102, 0 -- 3 - // 222, 165, 24 -- 6 - // 148, 222, 33 -- 9 - // 57, 181, 16 -- 12 - // 24, 132, 8 -- 15 - // TODO: This could probably be improved by iterating backwards - for (let i = 0; i < renderHeight; i++) { - const colorIndex = GRADIENT_COLOR_COUNT - 1 - Math.floor(i * multiplier); - barCanvasCtx.fillStyle = gradientColors[colorIndex]; - const y = height - i * PIXEL_DENSITY; - barCanvasCtx.fillRect(0, y, BAR_WIDTH, PIXEL_DENSITY); - } - return barCanvas; -} -function Visualizer(props: Props) { - const [barPeaks] = useState(() => new Array(NUM_BARS).fill(0)); - const [barPeakFrames] = useState(() => new Array(NUM_BARS).fill(0)); - +function Visualizer({ analyser }: Props) { useLayoutEffect(() => { - props.analyser.fftSize = 2048; - }, [props.analyser]); + analyser.fftSize = 2048; + }, [analyser.fftSize]); const colors = useTypedSelector(Selectors.getSkinColors); const style = useTypedSelector(Selectors.getVisualizerStyle); const status = useTypedSelector(Selectors.getMediaStatus); @@ -144,23 +61,6 @@ function Visualizer(props: Props) { const width = renderWidth * PIXEL_DENSITY; const height = renderHeight * PIXEL_DENSITY; - const bufferLength = useMemo(() => { - switch (style) { - case VISUALIZERS.OSCILLOSCOPE: - return props.analyser.fftSize; - case VISUALIZERS.BAR: - return props.analyser.frequencyBinCount; - default: - return 0; - } - }, [props.analyser.fftSize, props.analyser.frequencyBinCount, style]); - - const dataArray = useMemo(() => { - return new Uint8Array(bufferLength); - }, [bufferLength]); - const octaveBuckets = useMemo(() => { - return octaveBucketsForBufferLength(bufferLength); - }, [bufferLength]); const bgCanvas = useMemo(() => { return preRenderBg( @@ -172,139 +72,27 @@ function Visualizer(props: Props) { ); }, [colors, height, width, windowShade]); - const barCanvas = useMemo(() => { - return preRenderBar(height, colors, renderHeight); - }, [colors, height, renderHeight]); + const paintOscilloscopeFrame = usePaintOscilloscopeFrame({ + analyser, + height, + width, + renderWidth, + }); + const paintBarFrame = usePaintBarFrame({ + analyser, + height, + renderHeight, + }); + const paintBar = usePaintBar({ height, renderHeight }); - const printBar = useCallback( - ( - ctx: CanvasRenderingContext2D, - x: number, - barHeight: number, - peakHeight: number - ) => { - barHeight = Math.ceil(barHeight) * PIXEL_DENSITY; - peakHeight = Math.ceil(peakHeight) * PIXEL_DENSITY; - if (barHeight > 0 || peakHeight > 0) { - const y = height - barHeight; - // Draw the gradient - const b = BAR_WIDTH; - if (height > 0) { - ctx.drawImage(barCanvas, 0, y, b, height, x, y, b, height); - } - - // Draw the gray peak line - if (!windowShade) { - const peakY = height - peakHeight; - ctx.fillStyle = colors[PEAK_COLOR_INDEX]; - ctx.fillRect(x, peakY, b, PIXEL_DENSITY); - } - } - }, - [barCanvas, colors, height, windowShade] - ); - - const paintOscilloscopeFrame = useCallback( - (canvasCtx: CanvasRenderingContext2D) => { - props.analyser.getByteTimeDomainData(dataArray); - - canvasCtx.lineWidth = PIXEL_DENSITY; - - // Just use one of the viscolors for now - canvasCtx.strokeStyle = colors[18]; - - // Since dataArray has more values than we have pixels to display, we - // have to average several dataArray values per pixel. We call these - // groups slices. - // - // We use the 2x scale here since we only want to plot values for - // "real" pixels. - const sliceWidth = Math.floor(bufferLength / width) * PIXEL_DENSITY; - - const h = height; - - canvasCtx.beginPath(); - - // Iterate over the width of the canvas in "real" pixels. - for (let j = 0; j <= renderWidth; j++) { - const amplitude = sliceAverage(dataArray, sliceWidth, j); - const percentAmplitude = amplitude / 255; // dataArray gives us bytes - const y = (1 - percentAmplitude) * h; // flip y - const x = j * PIXEL_DENSITY; - - // Canvas coordinates are in the middle of the pixel by default. - // When we want to draw pixel perfect lines, we will need to - // account for that here - if (x === 0) { - canvasCtx.moveTo(x, y); - } else { - canvasCtx.lineTo(x, y); - } - } - canvasCtx.stroke(); - }, - [ - bufferLength, - colors, - dataArray, - height, - props.analyser, - renderWidth, - width, - ] - ); - - const paintBarFrame = useCallback( - (canvasCtx: CanvasRenderingContext2D) => { - props.analyser.getByteFrequencyData(dataArray); - const heightMultiplier = renderHeight / 256; - const xOffset = BAR_WIDTH + PIXEL_DENSITY; // Bar width, plus a pixel of spacing to the right. - for (let j = 0; j < NUM_BARS - 1; j++) { - const start = octaveBuckets[j]; - const end = octaveBuckets[j + 1]; - let amplitude = 0; - for (let k = start; k < end; k++) { - amplitude += dataArray[k]; - } - amplitude /= end - start; - - // The drop rate should probably be normalized to the rendering FPS, for now assume 60 FPS - let barPeak = - barPeaks[j] - BAR_PEAK_DROP_RATE * Math.pow(barPeakFrames[j], 2); - if (barPeak < amplitude) { - barPeak = amplitude; - barPeakFrames[j] = 0; - } else { - barPeakFrames[j] += 1; - } - barPeaks[j] = barPeak; - - printBar( - canvasCtx, - j * xOffset, - amplitude * heightMultiplier, - barPeak * heightMultiplier - ); - } - }, - [ - barPeakFrames, - barPeaks, - dataArray, - octaveBuckets, - printBar, - props.analyser, - renderHeight, - ] - ); const paintFrame = useCallback( - (canvasCtx: CanvasRenderingContext2D, canvas: HTMLCanvasElement) => { + (canvasCtx: CanvasRenderingContext2D) => { if (status !== MEDIA_STATUS.PLAYING) { return; } if (dummyVizData) { Object.entries(dummyVizData).forEach(([i, value]) => { - printBar(canvasCtx, Number(i), value, -1); + paintBar(canvasCtx, Number(i), value, -1); }); return; } @@ -318,17 +106,19 @@ function Visualizer(props: Props) { paintBarFrame(canvasCtx); break; default: - canvasCtx.clearRect(0, 0, canvas.width, canvas.height); + canvasCtx.clearRect(0, 0, width, height); } }, [ bgCanvas, dummyVizData, + height, + paintBar, paintBarFrame, paintOscilloscopeFrame, - printBar, status, style, + width, ] ); @@ -347,7 +137,7 @@ function Visualizer(props: Props) { let animationRequest: number | null = null; // Kick off the animation loop const loop = () => { - paintFrame(canvasCtx, canvas); + paintFrame(canvasCtx); animationRequest = window.requestAnimationFrame(loop); }; loop(); diff --git a/js/components/useBarVisualizer.ts b/js/components/useBarVisualizer.ts new file mode 100644 index 00000000..df340171 --- /dev/null +++ b/js/components/useBarVisualizer.ts @@ -0,0 +1,186 @@ +import { useMemo, useCallback, useState } from "react"; + +import * as Selectors from "../selectors"; +import { useTypedSelector } from "../hooks"; + +const PIXEL_DENSITY = 2; +const BAR_WIDTH = 3 * PIXEL_DENSITY; +const GRADIENT_COLOR_COUNT = 16; +const PEAK_COLOR_INDEX = 23; +const BAR_PEAK_DROP_RATE = 0.01; +const NUM_BARS = 20; + +function octaveBucketsForBufferLength(bufferLength: number): number[] { + 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) * bufferLength); + octaveBuckets[i] = octaveIdx; + } + + return octaveBuckets; +} + +function preRenderBar( + height: number, + colors: string[], + renderHeight: number +): HTMLCanvasElement { + /** + * The order of the colours is commented in the file: the fist two colours + * define the background and dots (check it to see what are the dots), the + * next 16 colours are the analyzer's colours from top to bottom, the next + * 5 colours are the oscilloscope's ones, from center to top/bottom, the + * last colour is for the analyzer's peak markers. + */ + + // Off-screen canvas for pre-rendering a single bar gradient + const barCanvas = document.createElement("canvas"); + barCanvas.width = BAR_WIDTH; + barCanvas.height = height; + + const offset = 2; // The first two colors are for the background; + const gradientColors = colors.slice(offset, offset + GRADIENT_COLOR_COUNT); + + const barCanvasCtx = barCanvas.getContext("2d"); + if (barCanvasCtx == null) { + throw new Error("Could not construct canvas context"); + } + const multiplier = GRADIENT_COLOR_COUNT / renderHeight; + // In shade mode, the five colors are, from top to bottom: + // 214, 102, 0 -- 3 + // 222, 165, 24 -- 6 + // 148, 222, 33 -- 9 + // 57, 181, 16 -- 12 + // 24, 132, 8 -- 15 + // TODO: This could probably be improved by iterating backwards + for (let i = 0; i < renderHeight; i++) { + const colorIndex = GRADIENT_COLOR_COUNT - 1 - Math.floor(i * multiplier); + barCanvasCtx.fillStyle = gradientColors[colorIndex]; + const y = height - i * PIXEL_DENSITY; + barCanvasCtx.fillRect(0, y, BAR_WIDTH, PIXEL_DENSITY); + } + return barCanvas; +} + +export function usePaintBar({ + renderHeight, + height, +}: { + renderHeight: number; + height: number; +}) { + const colors = useTypedSelector(Selectors.getSkinColors); + const getWindowShade = useTypedSelector(Selectors.getWindowShade); + const windowShade = getWindowShade("main"); + + const barCanvas = useMemo(() => { + return preRenderBar(height, colors, renderHeight); + }, [colors, height, renderHeight]); + + return useCallback( + ( + ctx: CanvasRenderingContext2D, + x: number, + barHeight: number, + peakHeight: number + ) => { + barHeight = Math.ceil(barHeight) * PIXEL_DENSITY; + peakHeight = Math.ceil(peakHeight) * PIXEL_DENSITY; + if (barHeight > 0 || peakHeight > 0) { + const y = height - barHeight; + // Draw the gradient + const b = BAR_WIDTH; + if (height > 0) { + ctx.drawImage(barCanvas, 0, y, b, height, x, y, b, height); + } + + // Draw the gray peak line + if (!windowShade) { + const peakY = height - peakHeight; + ctx.fillStyle = colors[PEAK_COLOR_INDEX]; + ctx.fillRect(x, peakY, b, PIXEL_DENSITY); + } + } + }, + [barCanvas, colors, height, windowShade] + ); +} + +export function usePaintBarFrame({ + renderHeight, + height, + analyser, +}: { + renderHeight: number; + height: number; + analyser: AnalyserNode; +}) { + const [barPeaks] = useState(() => new Array(NUM_BARS).fill(0)); + const [barPeakFrames] = useState(() => new Array(NUM_BARS).fill(0)); + const bufferLength = analyser.frequencyBinCount; + + const octaveBuckets = useMemo(() => { + return octaveBucketsForBufferLength(bufferLength); + }, [bufferLength]); + + const dataArray = useMemo(() => { + return new Uint8Array(bufferLength); + }, [bufferLength]); + + const paintBar = usePaintBar({ height, renderHeight }); + + return useCallback( + (canvasCtx: CanvasRenderingContext2D) => { + analyser.getByteFrequencyData(dataArray); + const heightMultiplier = renderHeight / 256; + const xOffset = BAR_WIDTH + PIXEL_DENSITY; // Bar width, plus a pixel of spacing to the right. + for (let j = 0; j < NUM_BARS - 1; j++) { + const start = octaveBuckets[j]; + const end = octaveBuckets[j + 1]; + let amplitude = 0; + for (let k = start; k < end; k++) { + amplitude += dataArray[k]; + } + amplitude /= end - start; + + // The drop rate should probably be normalized to the rendering FPS, for now assume 60 FPS + let barPeak = + barPeaks[j] - BAR_PEAK_DROP_RATE * Math.pow(barPeakFrames[j], 2); + if (barPeak < amplitude) { + barPeak = amplitude; + barPeakFrames[j] = 0; + } else { + barPeakFrames[j] += 1; + } + barPeaks[j] = barPeak; + + paintBar( + canvasCtx, + j * xOffset, + amplitude * heightMultiplier, + barPeak * heightMultiplier + ); + } + }, + [ + analyser, + barPeakFrames, + barPeaks, + dataArray, + octaveBuckets, + paintBar, + renderHeight, + ] + ); +} diff --git a/js/components/useOscilloscopeVisualizer.ts b/js/components/useOscilloscopeVisualizer.ts new file mode 100644 index 00000000..4b8870c5 --- /dev/null +++ b/js/components/useOscilloscopeVisualizer.ts @@ -0,0 +1,83 @@ +import { useMemo, useCallback } from "react"; + +import * as Selectors from "../selectors"; +import { useTypedSelector } from "../hooks"; + +const PIXEL_DENSITY = 2; + +// Return the average value in a slice of dataArray +function sliceAverage( + dataArray: Uint8Array, + sliceWidth: number, + sliceNumber: number +): number { + const start = sliceWidth * sliceNumber; + const end = start + sliceWidth; + let sum = 0; + for (let i = start; i < end; i++) { + sum += dataArray[i]; + } + return sum / sliceWidth; +} + +export function usePaintOscilloscopeFrame({ + analyser, + height, + width, + renderWidth, +}: { + analyser: AnalyserNode; + height: number; + width: number; + renderWidth: number; +}) { + const colors = useTypedSelector(Selectors.getSkinColors); + + const bufferLength = analyser.fftSize; + + const dataArray = useMemo(() => { + return new Uint8Array(bufferLength); + }, [bufferLength]); + + return useCallback( + (canvasCtx: CanvasRenderingContext2D) => { + analyser.getByteTimeDomainData(dataArray); + + canvasCtx.lineWidth = PIXEL_DENSITY; + + // Just use one of the viscolors for now + canvasCtx.strokeStyle = colors[18]; + + // Since dataArray has more values than we have pixels to display, we + // have to average several dataArray values per pixel. We call these + // groups slices. + // + // We use the 2x scale here since we only want to plot values for + // "real" pixels. + const sliceWidth = Math.floor(bufferLength / width) * PIXEL_DENSITY; + + const h = height; + + canvasCtx.beginPath(); + + // Iterate over the width of the canvas in "real" pixels. + for (let j = 0; j <= renderWidth; j++) { + const amplitude = sliceAverage(dataArray, sliceWidth, j); + const percentAmplitude = amplitude / 255; // dataArray gives us bytes + const y = (1 - percentAmplitude) * h; // flip y + const x = j * PIXEL_DENSITY; + + // Canvas coordinates are in the middle of the pixel by default. + // When we want to draw pixel perfect lines, we will need to + // account for that here + if (x === 0) { + canvasCtx.moveTo(x, y); + } else { + canvasCtx.lineTo(x, y); + } + } + canvasCtx.stroke(); + }, + [analyser, bufferLength, colors, dataArray, height, renderWidth, width] + ); +}