This repository has been archived on 2022-09-16. You can view files and clone it, but cannot push or open issues/pull-requests.
tlaternet-templates/src/music/components/visualizer.tsx

228 lines
6.7 KiB
TypeScript

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<three.Mesh>;
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<three.Mesh>;
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<VisualizerProps, State> {
private analyser?: AnalyserNode;
private canvas: React.RefObject<HTMLCanvasElement>;
private drawer?: CanvasDrawer;
constructor(props: VisualizerProps) {
super(props);
this.canvas = React.createRef();
}
render(): React.ReactNode {
return (
<canvas
ref={this.canvas}
style={{
display: "block",
}}
></canvas>
);
}
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;