import React from "react"; import * as three from "three"; import { State } from "../store"; type VisualizerProps = { audioContext: AudioContext; audioSource: AudioNode; }; class CanvasDrawer { private analyser: AnalyserNode; private canvas: HTMLCanvasElement; private analyserData: Float32Array; private boxes: Array; private camera: three.PerspectiveCamera; private renderer: three.WebGLRenderer; private scene: three.Scene; private angle: number; private animationFrame: number; private lastTime: number; constructor(analyser: AnalyserNode, canvas: HTMLCanvasElement) { this.analyser = analyser; this.canvas = canvas; // Set up analyser data storage this.analyserData = new Float32Array(analyser.frequencyBinCount); // Initialize the scene this.scene = new three.Scene(); // Make a bunch of boxes to represent the bars this.boxes = new Array(analyser.frequencyBinCount) as Array; const width = 2 / analyser.frequencyBinCount; for (let freq = 0; freq < analyser.frequencyBinCount; freq++) { const geometry = new three.BoxGeometry(1, 1, 1); const material = new three.MeshLambertMaterial({ color: new three.Color(0x99d1ce), }); const cube = new three.Mesh(geometry, material); cube.scale.set(width, 1e-6, width); cube.position.set(-1 + freq * width, 0, 0); this.scene.add(cube); this.boxes[freq] = cube; } // Add lights for shadowing const ambientLight = new three.AmbientLight(0xffffff, 0.4); this.scene.add(ambientLight); const directionalLight = new three.DirectionalLight(0xffffff, 1); directionalLight.position.set(-1, 0.3, -1); directionalLight.castShadow = true; this.scene.add(directionalLight); // Add a camera this.angle = 3; this.camera = new three.PerspectiveCamera( 70, canvas.width / canvas.height, 0.01, 10 ); this.camera.lookAt(0, 0, 0); this.scene.add(this.camera); this.rotateCamera(1); // Add a renderer this.renderer = new three.WebGLRenderer({ antialias: true, canvas: canvas, powerPreference: "low-power", }); this.renderer.setClearColor(new three.Color(0x0f0f0f)); this.renderer.setSize(canvas.width, canvas.height, false); // Set up canvas resizing window.addEventListener("resize", this.resize); // Run the first, set the first animation frame time and start requesting // animation frames this.resize(); this.lastTime = 0; this.animationFrame = requestAnimationFrame(this.render); } scaleBoxes() { const analyser = this.analyser; analyser.getFloatFrequencyData(this.analyserData); for (let freq = 0; freq < analyser.frequencyBinCount; freq++) { let height = analyser.maxDecibels / this.analyserData[freq]; if (height > 0.3) { height -= 0.3; } else { height = 1e-6; } this.boxes[freq].scale.y = height; } } rotateCamera(elapsed: number) { if (this.angle >= Math.PI * 2) { this.angle = 0; } else { this.angle += 0.1 * (elapsed / 1000); } const camera = this.camera; const angle = this.angle; camera.position.x = 1.01 * Math.sin(angle); camera.position.z = 1.01 * Math.cos(angle); /* camera.position.y = (1 - Math.abs(angle - 0.5) / 0.5); */ camera.lookAt(0, 0, 0); } stop() { if (this.animationFrame != 0) { cancelAnimationFrame(this.animationFrame); } } render = (time: number) => { // Set our animation frame to 0, so that if we stop, we don't try to cancel a past animation frame this.animationFrame = 0; // Update elapsed time const elapsed = time - this.lastTime; this.lastTime = time; const camera = this.camera; const renderer = this.renderer; const scene = this.scene; this.scaleBoxes(); this.rotateCamera(elapsed); renderer.render(scene, camera); this.animationFrame = requestAnimationFrame(this.render); }; resize = () => { const canvas = this.canvas; if (canvas.parentElement === null) { throw Error("Could not access canvas parent for size calculation"); } // This is stupid, but by setting the canvas proportions to 0 // for a split second the browser can actually figure out the // height of the parentElement. // // If the canvas is allowed to keep its height, it will // prevent the parentElement from being able to shrink because // its contents are filling it completely. // // I've seen others use `overflow: hidden` to achieve this // (and in fact seen it render in my browser on jsfiddle), but // that doesn't work here somehow. canvas.height = 0; canvas.width = 0; const height = canvas.parentElement.clientHeight; const width = canvas.parentElement.clientWidth; this.camera.aspect = width / height; this.camera.updateProjectionMatrix(); this.renderer.setSize(width, height, false); }; } class Visualizer extends React.Component { private analyser?: AnalyserNode; private canvas: React.RefObject; private drawer?: CanvasDrawer; constructor(props: VisualizerProps) { super(props); this.canvas = React.createRef(); } render(): React.ReactNode { return ( ); } componentDidMount(): void { if (this.canvas.current === null) { throw Error("Failed to create canvas; aborting"); } this.analyser = this.props.audioContext.createAnalyser(); this.analyser.fftSize = 2048; this.analyser.smoothingTimeConstant = 0.8; this.props.audioSource.connect(this.analyser); this.drawer = new CanvasDrawer(this.analyser, this.canvas.current); } componentWillUnmount(): void { if (!this.drawer || !this.analyser) { return; } this.drawer.stop(); this.props.audioSource.disconnect(this.analyser); } } export default Visualizer;