228 lines
6.7 KiB
TypeScript
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;
|