Integrate templates project
This commit is contained in:
parent
3a5d4b9756
commit
76f5246814
55 changed files with 11946 additions and 144 deletions
BIN
templates/src/music/assets/Mseq_-_Journey.mp3
Normal file
BIN
templates/src/music/assets/Mseq_-_Journey.mp3
Normal file
Binary file not shown.
2
templates/src/music/assets/Mseq_-_Journey.mp3.d.ts
vendored
Normal file
2
templates/src/music/assets/Mseq_-_Journey.mp3.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
declare const mseq: string;
|
||||
export default mseq;
|
44
templates/src/music/features/controls/Controls.tsx
Normal file
44
templates/src/music/features/controls/Controls.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import React from "react";
|
||||
|
||||
import Indicator from "../indicator/Indicator";
|
||||
import { useAppSelector } from "../../hooks";
|
||||
|
||||
function Controls() {
|
||||
const title = useAppSelector((state) => state.musicPlayer.title);
|
||||
|
||||
let titleLine = <div className="level-item">{title.name}</div>;
|
||||
|
||||
if (title.name === "Journey" && title.artist === "Mseq") {
|
||||
titleLine = (
|
||||
<div className="level-item is-size-7-mobile is-flex-shrink-1">
|
||||
<div>
|
||||
<a href="http://dig.ccmixter.org/files/Mseq/54702">
|
||||
Journey
|
||||
</a>
|
||||
by Mseq (c) copyright 2016 Licensed under a Creative
|
||||
Commons
|
||||
<a href="http://creativecommons.org/licenses/by-nc/3.0/">
|
||||
Attribution Noncommercial (3.0)
|
||||
</a>
|
||||
license. Ft: Admiral Bob,Texas Radio Fish
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="notification is-primary">
|
||||
<div className="level is-mobile">
|
||||
<div className="level-left is-flex-shrink-1">
|
||||
<Indicator />
|
||||
{titleLine}
|
||||
</div>
|
||||
<div className="level-right is-hidden-mobile">
|
||||
<div className="level-item">{title.artist}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Controls;
|
44
templates/src/music/features/indicator/Indicator.tsx
Normal file
44
templates/src/music/features/indicator/Indicator.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import React from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { useAppSelector, useAppDispatch } from "../../hooks";
|
||||
import { togglePlay, PlayState } from "../musicplayer/musicPlayerSlice";
|
||||
|
||||
function Indicator() {
|
||||
const playing = useAppSelector((state) => state.musicPlayer.playing);
|
||||
const muted = useAppSelector((state) => state.musicPlayer.muted);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const buttonClass = classNames({
|
||||
button: true,
|
||||
"is-primary": true,
|
||||
"level-item": true,
|
||||
"is-loading": playing === PlayState.Loading,
|
||||
});
|
||||
|
||||
const iconClass = classNames({
|
||||
fas: true,
|
||||
"fa-2x": true,
|
||||
"fa-muted": muted,
|
||||
"fa-play": playing === PlayState.Paused,
|
||||
"fa-pause": playing === PlayState.Playing,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
dispatch(togglePlay(null)).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
}}
|
||||
className={buttonClass}
|
||||
>
|
||||
<span className="icon is-medium">
|
||||
<i className={iconClass}></i>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default Indicator;
|
17
templates/src/music/features/musicplayer/MusicPlayer.tsx
Normal file
17
templates/src/music/features/musicplayer/MusicPlayer.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import React from "react";
|
||||
|
||||
import Controls from "../controls/Controls";
|
||||
import Visualizer from "../visualizer/Visualizer";
|
||||
|
||||
function MusicPlayer() {
|
||||
return (
|
||||
<div className="is-flex-grow-1 is-flex is-flex-direction-column">
|
||||
<Visualizer />
|
||||
<div className="is-flex-grow-0">
|
||||
<Controls />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MusicPlayer;
|
157
templates/src/music/features/musicplayer/musicPlayerSlice.ts
Normal file
157
templates/src/music/features/musicplayer/musicPlayerSlice.ts
Normal file
|
@ -0,0 +1,157 @@
|
|||
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
|
||||
import { RootState, AppDispatch } from "../../store";
|
||||
|
||||
//************************
|
||||
// Interface definitions *
|
||||
//************************
|
||||
|
||||
interface MusicPlayerState {
|
||||
muted: boolean;
|
||||
playing: PlayState;
|
||||
title: MusicPlayerTitle;
|
||||
}
|
||||
|
||||
interface MusicPlayerTitle {
|
||||
source: string;
|
||||
artist: string;
|
||||
name: string;
|
||||
album: string;
|
||||
length: number;
|
||||
}
|
||||
|
||||
enum PlayState {
|
||||
Playing = "Playing",
|
||||
Paused = "Paused",
|
||||
Loading = "Loading",
|
||||
}
|
||||
|
||||
//*********************
|
||||
// Music player logic *
|
||||
//*********************
|
||||
|
||||
class MusicPlayer {
|
||||
private context?: AudioContext;
|
||||
private source: HTMLAudioElement;
|
||||
private sourceNode?: MediaElementAudioSourceNode;
|
||||
private volume?: GainNode;
|
||||
private analyser?: AnalyserNode;
|
||||
|
||||
constructor() {
|
||||
this.source = new Audio();
|
||||
}
|
||||
|
||||
get audioAnalyser() {
|
||||
return this.analyser;
|
||||
}
|
||||
|
||||
set src(source: string) {
|
||||
this.source.src = source;
|
||||
}
|
||||
|
||||
togglePlay = async (
|
||||
_: null,
|
||||
{ getState }: { getState: () => RootState }
|
||||
): Promise<PlayState> => {
|
||||
if (this.context === undefined) {
|
||||
this.context = new AudioContext();
|
||||
this.sourceNode = this.context.createMediaElementSource(
|
||||
this.source
|
||||
);
|
||||
this.volume = this.context.createGain();
|
||||
this.analyser = this.context.createAnalyser();
|
||||
|
||||
this.analyser.fftSize = 2048;
|
||||
this.analyser.smoothingTimeConstant = 0.8;
|
||||
|
||||
this.sourceNode.connect(this.analyser);
|
||||
this.sourceNode.connect(this.volume);
|
||||
this.volume.connect(this.context.destination);
|
||||
}
|
||||
|
||||
const playing = getState().musicPlayer.playing;
|
||||
|
||||
switch (playing) {
|
||||
case PlayState.Playing:
|
||||
this.source.pause();
|
||||
return PlayState.Paused;
|
||||
case PlayState.Paused:
|
||||
case PlayState.Loading:
|
||||
// Chrome's extra cookie, it refuses to play if we
|
||||
// don't resume after the first user interaction.
|
||||
await this.context.resume();
|
||||
return this.source.play().then(() => PlayState.Playing);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const player = new MusicPlayer();
|
||||
|
||||
//*************************
|
||||
// Redux state management *
|
||||
//*************************
|
||||
|
||||
const initialState: MusicPlayerState = {
|
||||
muted: false,
|
||||
playing: PlayState.Paused,
|
||||
title: {
|
||||
source: "",
|
||||
artist: "",
|
||||
name: "",
|
||||
album: "",
|
||||
length: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export const musicPlayerSlice = createSlice({
|
||||
name: "musicPlayer",
|
||||
initialState,
|
||||
|
||||
reducers: {
|
||||
setSource: (state, action: PayloadAction<MusicPlayerTitle>) => {
|
||||
state.title = action.payload;
|
||||
player.src = state.title.source;
|
||||
},
|
||||
},
|
||||
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(togglePlay.pending, (state) => {
|
||||
// If we are currently paused or loading, then this is
|
||||
// actually an async call, otherwise we just
|
||||
// synchronously pause the music.
|
||||
if (state.playing !== PlayState.Playing) {
|
||||
state.playing = PlayState.Loading;
|
||||
}
|
||||
})
|
||||
.addCase(togglePlay.fulfilled, (state, { payload }) => {
|
||||
state.playing = payload;
|
||||
})
|
||||
.addCase(togglePlay.rejected, (state, { error }) => {
|
||||
if (error.message !== undefined) {
|
||||
console.error(`Could not play music: ${error.message}`);
|
||||
}
|
||||
|
||||
state.playing = PlayState.Paused;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const togglePlay = createAsyncThunk<
|
||||
PlayState,
|
||||
null,
|
||||
{ dispatch: AppDispatch; state: RootState }
|
||||
>("musicPlayer/togglePlay", player.togglePlay, {
|
||||
condition: (_, { getState }) => {
|
||||
const playing = getState().musicPlayer.playing;
|
||||
|
||||
if (playing == PlayState.Loading) {
|
||||
// Block updates when we're loading
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const { setSource } = musicPlayerSlice.actions;
|
||||
export { PlayState, player as musicPlayer };
|
||||
export type { MusicPlayerState };
|
||||
export default musicPlayerSlice.reducer;
|
342
templates/src/music/features/visualizer/Renderer.ts
Normal file
342
templates/src/music/features/visualizer/Renderer.ts
Normal file
|
@ -0,0 +1,342 @@
|
|||
import { Shader } from "./Shader";
|
||||
import { mat4 } from "gl-matrix";
|
||||
|
||||
import { Cube } from "./cube";
|
||||
import vertexSource from "./shaders/vertices.glsl";
|
||||
import fragmentSource from "./shaders/fragments.glsl";
|
||||
|
||||
const ROTATION_SPEED = 0.0;
|
||||
const BACKGROUND_COLOR = [0.0588235294118, 0.0588235294118, 0.0588235294118];
|
||||
|
||||
class RendererError extends Error {}
|
||||
|
||||
class Renderer {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private overlay: HTMLSpanElement;
|
||||
|
||||
private analyser: AnalyserNode;
|
||||
private analyserData: Uint8Array;
|
||||
|
||||
private lastFrameTime: number;
|
||||
private dTime: number;
|
||||
private nextAnimationFrame?: number;
|
||||
|
||||
private rotation: number;
|
||||
|
||||
private buffers: {
|
||||
indices?: WebGLBuffer;
|
||||
positions?: WebGLBuffer;
|
||||
normals?: WebGLBuffer;
|
||||
fft?: WebGLBuffer;
|
||||
velocitiesRead?: WebGLBuffer;
|
||||
velocitiesWrite?: WebGLBuffer;
|
||||
};
|
||||
|
||||
constructor(
|
||||
analyser: AnalyserNode,
|
||||
canvas: HTMLCanvasElement,
|
||||
overlay: HTMLSpanElement
|
||||
) {
|
||||
this.canvas = canvas;
|
||||
this.overlay = overlay;
|
||||
this.analyser = analyser;
|
||||
this.analyserData = new Uint8Array(analyser.frequencyBinCount);
|
||||
|
||||
this.lastFrameTime = 0;
|
||||
this.dTime = 0;
|
||||
this.rotation = 0;
|
||||
this.buffers = {};
|
||||
}
|
||||
|
||||
resizeAndDraw(
|
||||
gl: WebGL2RenderingContext,
|
||||
shader: Shader,
|
||||
observerData: ResizeObserverEntry | null
|
||||
) {
|
||||
if (this.canvas.parentElement === null) {
|
||||
throw new Error("renderer has been removed from dom");
|
||||
}
|
||||
|
||||
if (this.nextAnimationFrame) {
|
||||
cancelAnimationFrame(this.nextAnimationFrame);
|
||||
}
|
||||
|
||||
// Note: For this to work, it's *incredibly important* for the
|
||||
// canvas to be overflowable by its parent, and its parent to
|
||||
// have `overflow: hidden` set. If using a flexbox, this means
|
||||
// that the canvas has to be `position: absolute`.
|
||||
let width: number;
|
||||
let height: number;
|
||||
|
||||
if (observerData !== null && observerData.devicePixelContentBoxSize) {
|
||||
width = observerData.devicePixelContentBoxSize[0].inlineSize;
|
||||
height = observerData.devicePixelContentBoxSize[0].blockSize;
|
||||
} else {
|
||||
// Fallback; the above API is even newer than
|
||||
// ResizeObserver, and by setting the observerData to null
|
||||
// we can manually resize at least once without going
|
||||
// through the API.
|
||||
if (this.canvas.parentElement === null) {
|
||||
throw new Error("canvas parent disappeared");
|
||||
}
|
||||
// Note: This *requires* `box-sizing: border-box`
|
||||
({ width, height } =
|
||||
this.canvas.parentElement.getBoundingClientRect());
|
||||
}
|
||||
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
|
||||
gl.viewport(0, 0, this.canvas.clientWidth, this.canvas.clientHeight);
|
||||
this.updateProjection(gl, shader);
|
||||
|
||||
// ResizeObserver will call when we should draw, so do our own
|
||||
// time calculation and draw the scene.
|
||||
this.updateTime(performance.now());
|
||||
this.drawScene(gl, shader);
|
||||
}
|
||||
|
||||
updateTime(time: number) {
|
||||
this.dTime = time - this.lastFrameTime;
|
||||
this.lastFrameTime = time;
|
||||
}
|
||||
|
||||
initializeScene() {
|
||||
if (this.canvas.parentElement === null) {
|
||||
throw new Error("canvas was not added to page");
|
||||
}
|
||||
|
||||
const gl = this.canvas.getContext("webgl2");
|
||||
if (gl === null) {
|
||||
throw new RendererError("WebGL (2) is unsupported on this browser");
|
||||
}
|
||||
|
||||
const shader = Shader.builder(gl)
|
||||
.addShader(vertexSource, gl.VERTEX_SHADER)
|
||||
.addShader(fragmentSource, gl.FRAGMENT_SHADER)
|
||||
.addAttribute("aVertexPosition")
|
||||
.addAttribute("aVertexNormal")
|
||||
.addAttribute("aHeight")
|
||||
.addUniforms("uProjectionMatrix")
|
||||
.addUniforms("uModelViewMatrix")
|
||||
.addUniforms("uNormalMatrix")
|
||||
.build();
|
||||
|
||||
this.initGL(gl, shader);
|
||||
this.updateProjection(gl, shader);
|
||||
this.initBuffers(gl);
|
||||
|
||||
try {
|
||||
const observer = new ResizeObserver((elements) => {
|
||||
// We only observe one element
|
||||
const element = elements[0];
|
||||
this.resizeAndDraw(gl, shader, element);
|
||||
});
|
||||
observer.observe(this.canvas.parentElement);
|
||||
} catch (error) {
|
||||
// If the browser does not support ResizeObserver, we
|
||||
// simply don't resize. Resizing is hard enough, just use
|
||||
// a modern browser.
|
||||
if (error instanceof ReferenceError) {
|
||||
console.warn(
|
||||
"Browser does not support `ResizeObserver`. Canvas resizing will be disabled."
|
||||
);
|
||||
} else throw error;
|
||||
}
|
||||
this.resizeAndDraw(gl, shader, null);
|
||||
}
|
||||
|
||||
updateProjection(gl: WebGLRenderingContext, shader: Shader) {
|
||||
const projectionMatrix = mat4.create();
|
||||
mat4.perspective(
|
||||
projectionMatrix,
|
||||
(45 * Math.PI) / 180,
|
||||
gl.canvas.clientWidth / gl.canvas.clientHeight,
|
||||
0.1,
|
||||
100.0
|
||||
);
|
||||
gl.uniformMatrix4fv(
|
||||
shader.getUniform("uProjectionMatrix"),
|
||||
false,
|
||||
projectionMatrix
|
||||
);
|
||||
}
|
||||
|
||||
initBuffers(gl: WebGLRenderingContext) {
|
||||
// Scale down the unit cube before we use it
|
||||
Cube.vertices = Cube.vertices.map(
|
||||
(num: number) => num / this.analyser.frequencyBinCount
|
||||
);
|
||||
|
||||
// Position buffer
|
||||
const positionBuffer = gl.createBuffer();
|
||||
|
||||
if (positionBuffer === null) {
|
||||
throw new Error("could not initialize position buffer");
|
||||
}
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, Cube.vertices, gl.STATIC_DRAW);
|
||||
this.buffers.positions = positionBuffer;
|
||||
|
||||
// Index buffer
|
||||
const indexBuffer = gl.createBuffer();
|
||||
|
||||
if (indexBuffer === null) {
|
||||
throw new Error("could not initialize index buffer");
|
||||
}
|
||||
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
|
||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, Cube.indices, gl.STATIC_DRAW);
|
||||
this.buffers.indices = indexBuffer;
|
||||
|
||||
// Surface normal buffer
|
||||
const normalBuffer = gl.createBuffer();
|
||||
|
||||
if (normalBuffer === null) {
|
||||
throw new Error("could not initialize normal buffer");
|
||||
}
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, Cube.normals, gl.STATIC_DRAW);
|
||||
this.buffers.normals = normalBuffer;
|
||||
|
||||
// fft data buffer
|
||||
const fftBuffer = gl.createBuffer();
|
||||
|
||||
if (fftBuffer === null) {
|
||||
throw new Error("could not initialize fft buffer");
|
||||
}
|
||||
|
||||
// No need to initialize this buffer here since we will be
|
||||
// updating it as soon as we start rendering anyway.
|
||||
this.buffers.fft = fftBuffer;
|
||||
}
|
||||
|
||||
initGL(gl: WebGLRenderingContext, shader: Shader) {
|
||||
gl.useProgram(shader.program);
|
||||
gl.clearColor(
|
||||
BACKGROUND_COLOR[0],
|
||||
BACKGROUND_COLOR[1],
|
||||
BACKGROUND_COLOR[2],
|
||||
1.0
|
||||
);
|
||||
gl.clearDepth(1.0);
|
||||
gl.enable(gl.DEPTH_TEST);
|
||||
gl.depthFunc(gl.LESS);
|
||||
}
|
||||
|
||||
updateMatrices(gl: WebGLRenderingContext, shader: Shader) {
|
||||
this.rotation += (this.dTime / 1000.0) * ROTATION_SPEED;
|
||||
|
||||
const modelViewMatrix = mat4.create();
|
||||
mat4.translate(modelViewMatrix, modelViewMatrix, [
|
||||
0.0,
|
||||
0.025,
|
||||
-((this.analyser.frequencyBinCount / gl.canvas.clientWidth) * 3),
|
||||
]);
|
||||
mat4.rotateX(modelViewMatrix, modelViewMatrix, Math.PI / 16);
|
||||
mat4.rotateY(modelViewMatrix, modelViewMatrix, this.rotation);
|
||||
mat4.translate(modelViewMatrix, modelViewMatrix, [-1.0, 0.0, 0.0]);
|
||||
gl.uniformMatrix4fv(
|
||||
shader.getUniform("uModelViewMatrix"),
|
||||
false,
|
||||
modelViewMatrix
|
||||
);
|
||||
|
||||
const normalMatrix = mat4.create();
|
||||
mat4.invert(normalMatrix, modelViewMatrix);
|
||||
mat4.transpose(normalMatrix, normalMatrix);
|
||||
gl.uniformMatrix4fv(
|
||||
shader.getUniform("uNormalMatrix"),
|
||||
false,
|
||||
normalMatrix
|
||||
);
|
||||
}
|
||||
|
||||
updateBuffers(gl: WebGL2RenderingContext, shader: Shader) {
|
||||
if (
|
||||
this.buffers.indices === undefined ||
|
||||
this.buffers.positions === undefined ||
|
||||
this.buffers.normals === undefined ||
|
||||
this.buffers.fft === undefined
|
||||
) {
|
||||
throw new Error("failed to create buffers before rendering");
|
||||
}
|
||||
|
||||
// Update cube buffers
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.positions);
|
||||
gl.vertexAttribPointer(
|
||||
shader.getAttribute("aVertexPosition"),
|
||||
3,
|
||||
gl.FLOAT,
|
||||
false,
|
||||
0,
|
||||
0
|
||||
);
|
||||
gl.enableVertexAttribArray(shader.getAttribute("aVertexPosition"));
|
||||
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.buffers.indices);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.normals);
|
||||
gl.vertexAttribPointer(
|
||||
shader.getAttribute("aVertexNormal"),
|
||||
3,
|
||||
gl.FLOAT,
|
||||
false,
|
||||
0,
|
||||
0
|
||||
);
|
||||
gl.enableVertexAttribArray(shader.getAttribute("aVertexNormal"));
|
||||
|
||||
// Update fft
|
||||
this.analyser.getByteFrequencyData(this.analyserData);
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.fft);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, this.analyserData, gl.STREAM_DRAW);
|
||||
gl.vertexAttribPointer(
|
||||
shader.getAttribute("aHeight"),
|
||||
1,
|
||||
gl.UNSIGNED_BYTE,
|
||||
false,
|
||||
0,
|
||||
0
|
||||
);
|
||||
gl.vertexAttribDivisor(shader.getAttribute("aHeight"), 1);
|
||||
gl.enableVertexAttribArray(shader.getAttribute("aHeight"));
|
||||
}
|
||||
|
||||
drawScene(gl: WebGL2RenderingContext, shader: Shader) {
|
||||
this.updateMatrices(gl, shader);
|
||||
this.updateBuffers(gl, shader);
|
||||
|
||||
let cpuTime = 0;
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
cpuTime = Math.round(performance.now() - this.lastFrameTime);
|
||||
}
|
||||
|
||||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||
gl.drawElementsInstanced(
|
||||
gl.TRIANGLES,
|
||||
36,
|
||||
gl.UNSIGNED_SHORT,
|
||||
0,
|
||||
this.analyser.frequencyBinCount
|
||||
);
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
const gpuTime = Math.round(performance.now() - this.lastFrameTime);
|
||||
const dTime = Math.round(this.dTime);
|
||||
|
||||
this.overlay.innerText = `${dTime}ms (${cpuTime}ms / ${gpuTime}ms)`;
|
||||
}
|
||||
|
||||
this.nextAnimationFrame = requestAnimationFrame((time) => {
|
||||
this.updateTime(time);
|
||||
|
||||
this.drawScene(gl, shader);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { Renderer, RendererError };
|
183
templates/src/music/features/visualizer/Shader.ts
Normal file
183
templates/src/music/features/visualizer/Shader.ts
Normal file
|
@ -0,0 +1,183 @@
|
|||
type ShaderType =
|
||||
| WebGLRenderingContext["VERTEX_SHADER"]
|
||||
| WebGLRenderingContext["FRAGMENT_SHADER"];
|
||||
|
||||
interface ShaderSource {
|
||||
source: string;
|
||||
kind: ShaderType;
|
||||
}
|
||||
|
||||
type ShaderAttributes = Map<string, number>;
|
||||
type ShaderUniforms = Map<string, WebGLUniformLocation>;
|
||||
|
||||
class ShaderError extends Error {}
|
||||
|
||||
class Shader {
|
||||
private program_: WebGLProgram;
|
||||
private attributes_: ShaderAttributes;
|
||||
private uniforms_: ShaderUniforms;
|
||||
|
||||
constructor(
|
||||
program: WebGLProgram,
|
||||
attributes: ShaderAttributes,
|
||||
uniforms: ShaderUniforms
|
||||
) {
|
||||
this.program_ = program;
|
||||
this.attributes_ = attributes;
|
||||
this.uniforms_ = uniforms;
|
||||
}
|
||||
|
||||
static builder(gl: WebGLRenderingContext): ShaderBuilder {
|
||||
return new ShaderBuilder(gl);
|
||||
}
|
||||
|
||||
get program(): WebGLProgram {
|
||||
return this.program_;
|
||||
}
|
||||
|
||||
public getAttribute(name: string): number {
|
||||
const attribute = this.attributes_.get(name);
|
||||
|
||||
if (attribute === undefined) {
|
||||
throw new ShaderError(`undefined shader attribute: ${name}`);
|
||||
}
|
||||
|
||||
return attribute;
|
||||
}
|
||||
|
||||
public getUniform(name: string): WebGLUniformLocation {
|
||||
const uniform = this.uniforms_.get(name);
|
||||
|
||||
if (uniform === undefined) {
|
||||
throw new ShaderError(`undefined shader uniform: ${name}`);
|
||||
}
|
||||
|
||||
return uniform;
|
||||
}
|
||||
|
||||
get uniforms(): ShaderUniforms {
|
||||
return this.uniforms_;
|
||||
}
|
||||
}
|
||||
|
||||
class ShaderBuilder {
|
||||
private gl: WebGLRenderingContext;
|
||||
private sources: Array<ShaderSource>;
|
||||
private attributes: Array<string>;
|
||||
private uniforms: Array<string>;
|
||||
|
||||
public constructor(gl: WebGLRenderingContext) {
|
||||
this.gl = gl;
|
||||
this.sources = new Array<ShaderSource>();
|
||||
this.attributes = new Array<string>();
|
||||
this.uniforms = new Array<string>();
|
||||
}
|
||||
|
||||
public addShader(source: string, kind: ShaderType): ShaderBuilder {
|
||||
this.sources.push({ source, kind });
|
||||
return this;
|
||||
}
|
||||
|
||||
public addAttribute(name: string): ShaderBuilder {
|
||||
this.attributes.push(name);
|
||||
return this;
|
||||
}
|
||||
|
||||
public addUniforms(name: string): ShaderBuilder {
|
||||
this.uniforms.push(name);
|
||||
return this;
|
||||
}
|
||||
|
||||
public build(): Shader {
|
||||
// Load, compile and link shader sources
|
||||
const shaders = this.sources.map(({ source, kind }) => {
|
||||
return this.loadShader(source, kind);
|
||||
});
|
||||
|
||||
const shaderProgram = this.gl.createProgram();
|
||||
if (shaderProgram === null) {
|
||||
throw new ShaderError("failed to create shader program");
|
||||
}
|
||||
|
||||
for (const shader of shaders) {
|
||||
this.gl.attachShader(shaderProgram, shader);
|
||||
}
|
||||
|
||||
this.gl.linkProgram(shaderProgram);
|
||||
if (!this.gl.getProgramParameter(shaderProgram, this.gl.LINK_STATUS)) {
|
||||
let message = "failed to link shader program";
|
||||
const log = this.gl.getProgramInfoLog(shaderProgram);
|
||||
if (log !== null) {
|
||||
message = `failed to link shader program: ${log}`;
|
||||
}
|
||||
|
||||
throw new ShaderError(message);
|
||||
}
|
||||
|
||||
// Find attribute and uniform locations
|
||||
const attributes = this.attributes.reduce((acc, attribute) => {
|
||||
const attributeLocation = this.gl.getAttribLocation(
|
||||
shaderProgram,
|
||||
attribute
|
||||
);
|
||||
|
||||
if (attributeLocation === -1) {
|
||||
throw new ShaderError(
|
||||
`shader attribute '${attribute}' could not be found`
|
||||
);
|
||||
}
|
||||
|
||||
return new Map<string, number>([
|
||||
...acc,
|
||||
[attribute, attributeLocation],
|
||||
]);
|
||||
}, new Map<string, number>());
|
||||
|
||||
const uniforms = this.uniforms.reduce((acc, uniform) => {
|
||||
const uniformLocation = this.gl.getUniformLocation(
|
||||
shaderProgram,
|
||||
uniform
|
||||
);
|
||||
|
||||
if (uniformLocation === null) {
|
||||
throw new ShaderError(
|
||||
`shader uniform '${uniform}' could not be found`
|
||||
);
|
||||
}
|
||||
|
||||
return new Map<string, WebGLUniformLocation>([
|
||||
...acc,
|
||||
[uniform, uniformLocation],
|
||||
]);
|
||||
}, new Map<string, WebGLUniformLocation>());
|
||||
|
||||
// Build actual shader object
|
||||
return new Shader(shaderProgram, attributes, uniforms);
|
||||
}
|
||||
|
||||
private loadShader(source: string, kind: ShaderType): WebGLShader {
|
||||
const shader = this.gl.createShader(kind);
|
||||
if (shader === null) {
|
||||
throw new ShaderError(`failed to initialize shader "${source}"`);
|
||||
}
|
||||
|
||||
this.gl.shaderSource(shader, source);
|
||||
this.gl.compileShader(shader);
|
||||
|
||||
if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
|
||||
let message = `failed to compile shader "${source}"`;
|
||||
const log = this.gl.getShaderInfoLog(shader);
|
||||
if (log !== null) {
|
||||
message = `failed to compile shader "${source}": ${log}`;
|
||||
}
|
||||
|
||||
this.gl.deleteShader(shader);
|
||||
|
||||
throw new ShaderError(message);
|
||||
}
|
||||
|
||||
return shader;
|
||||
}
|
||||
}
|
||||
|
||||
export { Shader, ShaderError };
|
139
templates/src/music/features/visualizer/Visualizer.tsx
Normal file
139
templates/src/music/features/visualizer/Visualizer.tsx
Normal file
|
@ -0,0 +1,139 @@
|
|||
import React, { useCallback, useState } from "react";
|
||||
import { Renderer, RendererError } from "./Renderer";
|
||||
import { ShaderError } from "./Shader";
|
||||
|
||||
import { useAppSelector } from "../../hooks";
|
||||
import { PlayState, musicPlayer } from "../musicplayer/musicPlayerSlice";
|
||||
|
||||
function Visualizer() {
|
||||
const playing = useAppSelector((state) => state.musicPlayer.playing);
|
||||
const rendererState = useState<Renderer | null>(null);
|
||||
let renderer = rendererState[0];
|
||||
const setRenderer = rendererState[1];
|
||||
const [renderError, setRenderError] = useState<JSX.Element | null>(null);
|
||||
|
||||
const visualizer = useCallback(
|
||||
(visualizer: HTMLDivElement | null) => {
|
||||
// TODO(tlater): Clean up state management. This is all
|
||||
// but trivial; there's seemingly no good place to keep
|
||||
// these big api objects (WebGLRenderingcontext or
|
||||
// AudioContext).
|
||||
//
|
||||
// It's tricky, too, because obviously react expects to be
|
||||
// in control of the DOM, and be allowed to delete our
|
||||
// canvas and create a new one.
|
||||
//
|
||||
// For the moment, this works, but it's a definite hack.
|
||||
if (renderer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Until we start playing music, there is nothing to render.
|
||||
if (playing !== PlayState.Playing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (musicPlayer.audioAnalyser === undefined) {
|
||||
throw new Error("MusicPlayer analyser was not set up on time");
|
||||
}
|
||||
|
||||
// If we're rendering an error message, we won't be
|
||||
// setting up the visualizer.
|
||||
//
|
||||
// Also, nonintuitively, renderError will be null here on
|
||||
// subsequent iterations, so we can't rely on it to
|
||||
// identify errors.
|
||||
if (visualizer === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = visualizer.children[0];
|
||||
const overlay = visualizer.children[1];
|
||||
|
||||
if (
|
||||
!(canvas instanceof HTMLCanvasElement) ||
|
||||
!(overlay instanceof HTMLSpanElement)
|
||||
) {
|
||||
throw new Error(
|
||||
"react did not create our visualizer div correctly"
|
||||
);
|
||||
}
|
||||
|
||||
if (renderer === null) {
|
||||
renderer = new Renderer(
|
||||
musicPlayer.audioAnalyser,
|
||||
canvas,
|
||||
overlay
|
||||
);
|
||||
setRenderer(renderer);
|
||||
}
|
||||
|
||||
try {
|
||||
renderer.initializeScene();
|
||||
} catch (error) {
|
||||
// Log so we don't lose the stack trace
|
||||
console.log(error);
|
||||
|
||||
if (error instanceof ShaderError) {
|
||||
setRenderError(
|
||||
<span>
|
||||
Failed to compile shader; This is a bug, feel free
|
||||
to contact me with this error message:
|
||||
<pre>
|
||||
<code className="has-text-danger">
|
||||
{error.message}
|
||||
</code>
|
||||
</pre>
|
||||
</span>
|
||||
);
|
||||
} else if (error instanceof RendererError) {
|
||||
setRenderError(
|
||||
<span>
|
||||
This browser does not support WebGL 2, sadly. This
|
||||
demo uses WebGL and specifically instanced drawing,
|
||||
so unfortunately this means it can't run on your
|
||||
browser/device.
|
||||
</span>
|
||||
);
|
||||
} else if (error instanceof Error) {
|
||||
setRenderError(
|
||||
<span>
|
||||
Something went very wrong; apologies, either your
|
||||
browser is not behaving or there's a serious bug.
|
||||
You can contact me with this error message:
|
||||
<pre>
|
||||
<code className="has-text-danger">
|
||||
{error.message}
|
||||
</code>
|
||||
</pre>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
setRenderError(
|
||||
<span>
|
||||
Something went very wrong; apologies, either your
|
||||
browser is not behaving or there's a serious bug.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[playing, renderer, musicPlayer.audioAnalyser]
|
||||
);
|
||||
|
||||
if (renderError === null) {
|
||||
return (
|
||||
<div
|
||||
ref={visualizer}
|
||||
className="is-flex-grow-1 is-clipped is-relative"
|
||||
>
|
||||
<canvas className="is-block is-absolute is-border-box"></canvas>
|
||||
<span className="is-bottom-left"></span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return renderError;
|
||||
}
|
||||
}
|
||||
|
||||
export default Visualizer;
|
84
templates/src/music/features/visualizer/cube.ts
Normal file
84
templates/src/music/features/visualizer/cube.ts
Normal file
|
@ -0,0 +1,84 @@
|
|||
/** * A hand-written 3d model of a cube.
|
||||
*
|
||||
* If this ever needs to be more than this, consider moving it to a
|
||||
* proper .obj model.
|
||||
*/
|
||||
const Cube = {
|
||||
// prettier-ignore
|
||||
vertices: new Float32Array([
|
||||
-1.0, -1.0, 1.0,
|
||||
1.0, -1.0, 1.0,
|
||||
1.0, 1.0, 1.0,
|
||||
-1.0, 1.0, 1.0,
|
||||
|
||||
-1.0, -1.0, -1.0,
|
||||
-1.0, 1.0, -1.0,
|
||||
1.0, 1.0, -1.0,
|
||||
1.0, -1.0, -1.0,
|
||||
|
||||
-1.0, 1.0, -1.0,
|
||||
-1.0, 1.0, 1.0,
|
||||
1.0, 1.0, 1.0,
|
||||
1.0, 1.0, -1.0,
|
||||
|
||||
-1.0, -1.0, -1.0,
|
||||
1.0, -1.0, -1.0,
|
||||
1.0, -1.0, 1.0,
|
||||
-1.0, -1.0, 1.0,
|
||||
|
||||
1.0, -1.0, -1.0,
|
||||
1.0, 1.0, -1.0,
|
||||
1.0, 1.0, 1.0,
|
||||
1.0, -1.0, 1.0,
|
||||
|
||||
-1.0, -1.0, -1.0,
|
||||
-1.0, -1.0, 1.0,
|
||||
-1.0, 1.0, 1.0,
|
||||
-1.0, 1.0, -1.0,
|
||||
]),
|
||||
|
||||
// prettier-ignore
|
||||
indices: new Uint16Array([
|
||||
0, 1, 2, 0, 2, 3,
|
||||
4, 5, 6, 4, 6, 7,
|
||||
8, 9, 10, 8, 10, 11,
|
||||
12, 13, 14, 12, 14, 15,
|
||||
16, 17, 18, 16, 18, 19,
|
||||
20, 21, 22, 20, 22, 23,
|
||||
]),
|
||||
|
||||
// prettier-ignore
|
||||
normals: new Float32Array([
|
||||
0.0, 0.0, 1.0,
|
||||
0.0, 0.0, 1.0,
|
||||
0.0, 0.0, 1.0,
|
||||
0.0, 0.0, 1.0,
|
||||
|
||||
0.0, 0.0, -1.0,
|
||||
0.0, 0.0, -1.0,
|
||||
0.0, 0.0, -1.0,
|
||||
0.0, 0.0, -1.0,
|
||||
|
||||
0.0, 1.0, 0.0,
|
||||
0.0, 1.0, 0.0,
|
||||
0.0, 1.0, 0.0,
|
||||
0.0, 1.0, 0.0,
|
||||
|
||||
0.0, -1.0, 0.0,
|
||||
0.0, -1.0, 0.0,
|
||||
0.0, -1.0, 0.0,
|
||||
0.0, -1.0, 0.0,
|
||||
|
||||
1.0, 0.0, 0.0,
|
||||
1.0, 0.0, 0.0,
|
||||
1.0, 0.0, 0.0,
|
||||
1.0, 0.0, 0.0,
|
||||
|
||||
-1.0, 0.0, 0.0,
|
||||
-1.0, 0.0, 0.0,
|
||||
-1.0, 0.0, 0.0,
|
||||
-1.0, 0.0, 0.0
|
||||
]),
|
||||
};
|
||||
|
||||
export { Cube };
|
|
@ -0,0 +1,12 @@
|
|||
#version 300 es
|
||||
// FRAGMENT SHADER
|
||||
//
|
||||
// Basic fragment shader, just passes along colors, we don't do much
|
||||
// with textures or anything else complex in this project.
|
||||
|
||||
precision highp float;
|
||||
|
||||
flat in vec4 vColor;
|
||||
out vec4 color;
|
||||
|
||||
void main() { color = vColor; }
|
2
templates/src/music/features/visualizer/shaders/fragments.glsl.d.ts
vendored
Normal file
2
templates/src/music/features/visualizer/shaders/fragments.glsl.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
declare const fragments: string;
|
||||
export default fragments;
|
|
@ -0,0 +1,50 @@
|
|||
#version 300 es
|
||||
// VERTEX SHADER
|
||||
//
|
||||
// Takes vertices of a unit cube, scales them up along Y according to
|
||||
// aHeight, and colors them with basic diffuse shading.
|
||||
|
||||
#define CLEAR_COLOR vec4(0.0588235294118, 0.0588235294118, 0.0588235294118, 1.0)
|
||||
#define BASE_COLOR vec3(0.6, 0.819607843137, 0.807843137255)
|
||||
#define AMBIENT_LIGHT vec3(0.3, 0.3, 0.3)
|
||||
#define LIGHT_DIRECTION normalize(vec3(0.85, 0.8, 0.75))
|
||||
#define LIGHT_COLOR vec3(1.0, 1.0, 1.0)
|
||||
|
||||
precision highp float;
|
||||
|
||||
layout(location = 0) in vec4 aVertexPosition;
|
||||
layout(location = 1) in vec3 aVertexNormal;
|
||||
layout(location = 2) in float aHeight;
|
||||
flat out vec4 vColor;
|
||||
|
||||
uniform mat4 uModelViewMatrix;
|
||||
uniform mat4 uProjectionMatrix;
|
||||
uniform mat4 uNormalMatrix;
|
||||
|
||||
void main() {
|
||||
// The X position of each vertex depends on its cube's instance;
|
||||
// they should align to the X axis.
|
||||
float instanceX =
|
||||
float(gl_InstanceID * 2) * abs(aVertexPosition.x) + aVertexPosition.x;
|
||||
// To scale the boxes by their frequencies, scale vertex Y by the
|
||||
// frequency.
|
||||
float vertexY = aVertexPosition.y * aHeight;
|
||||
|
||||
gl_Position = uProjectionMatrix * uModelViewMatrix *
|
||||
vec4(instanceX, vertexY, aVertexPosition.zw);
|
||||
|
||||
if (aHeight == 0.0) {
|
||||
// Don't render cubes that don't currently have a height
|
||||
// (frequency = 0)
|
||||
vColor = CLEAR_COLOR;
|
||||
} else {
|
||||
// Properly shade and color any other cubes
|
||||
vec4 transformedNormal = uNormalMatrix * vec4(aVertexNormal, 1.0);
|
||||
float directionalLight =
|
||||
max(dot(transformedNormal.xyz, LIGHT_DIRECTION), 0.0);
|
||||
vec3 appliedColor =
|
||||
BASE_COLOR * (directionalLight * LIGHT_COLOR + AMBIENT_LIGHT);
|
||||
|
||||
vColor = vec4(appliedColor.rgb, 1.0);
|
||||
}
|
||||
}
|
2
templates/src/music/features/visualizer/shaders/vertices.glsl.d.ts
vendored
Normal file
2
templates/src/music/features/visualizer/shaders/vertices.glsl.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
declare const vertices: string;
|
||||
export default vertices;
|
5
templates/src/music/hooks.ts
Normal file
5
templates/src/music/hooks.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
|
||||
import type { RootState, AppDispatch } from "./store";
|
||||
|
||||
export const useAppDispatch: () => AppDispatch = useDispatch;
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
31
templates/src/music/index.tsx
Normal file
31
templates/src/music/index.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Provider } from "react-redux";
|
||||
|
||||
import store from "./store";
|
||||
import MusicPlayer from "./features/musicplayer/MusicPlayer";
|
||||
import { setSource } from "./features/musicplayer/musicPlayerSlice";
|
||||
import mseq from "./assets/Mseq_-_Journey.mp3";
|
||||
|
||||
const rootElement = document.getElementById("playerUI");
|
||||
|
||||
if (rootElement === null) {
|
||||
throw Error("DOM seems to have failed to load. Something went very wrong.");
|
||||
}
|
||||
|
||||
const root = createRoot(rootElement);
|
||||
root.render(
|
||||
<Provider store={store}>
|
||||
<MusicPlayer />
|
||||
</Provider>
|
||||
);
|
||||
|
||||
store.dispatch(
|
||||
setSource({
|
||||
source: mseq,
|
||||
artist: "Mseq",
|
||||
name: "Journey",
|
||||
album: "Unknown album",
|
||||
length: 192052244,
|
||||
})
|
||||
);
|
18
templates/src/music/music.scss
Normal file
18
templates/src/music/music.scss
Normal file
|
@ -0,0 +1,18 @@
|
|||
$fa-font-path: "npm:@fortawesome/fontawesome-free/webfonts";
|
||||
|
||||
@import "~/node_modules/@fortawesome/fontawesome-free/scss/fontawesome";
|
||||
@import "~/node_modules/@fortawesome/fontawesome-free/scss/solid";
|
||||
|
||||
.is-border-box {
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.is-absolute {
|
||||
position: absolute !important;
|
||||
}
|
||||
|
||||
.is-bottom-left {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: absolute !important;
|
||||
}
|
15
templates/src/music/player.ts
Normal file
15
templates/src/music/player.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
class Player {
|
||||
constructor() {
|
||||
console.info("Test");
|
||||
}
|
||||
}
|
||||
|
||||
let player: Player | null = null;
|
||||
|
||||
export default () => {
|
||||
if (player === null) {
|
||||
player = new Player();
|
||||
}
|
||||
|
||||
return player;
|
||||
};
|
13
templates/src/music/store.ts
Normal file
13
templates/src/music/store.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import musicPlayerReducer from "./features/musicplayer/musicPlayerSlice";
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
musicPlayer: musicPlayerReducer,
|
||||
},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
||||
export default store;
|
Loading…
Add table
Add a link
Reference in a new issue