├── .gitignore ├── bundler ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js ├── package-lock.json ├── package.json ├── readme.md ├── readme └── screenshot.png ├── src ├── audio-midi-particle-controller.js ├── audio.js ├── engine.js ├── gui.js ├── index.html ├── midi.js ├── particles.js ├── script.js ├── shaders │ ├── fragment.glsl │ └── vertex.glsl └── style.css └── static ├── .gitkeep └── perlin.jpeg /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .vscode/launch.json 4 | -------------------------------------------------------------------------------- /bundler/webpack.common.js: -------------------------------------------------------------------------------- 1 | import CopyWebpackPlugin from "copy-webpack-plugin"; 2 | import HtmlWebpackPlugin from "html-webpack-plugin"; 3 | import MiniCSSExtractPlugin from "mini-css-extract-plugin"; 4 | import path, { resolve } from "path"; 5 | import { fileURLToPath } from "url"; 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = path.dirname(__filename); 8 | 9 | export default { 10 | entry: resolve(__dirname, "../src") + "/script.js", 11 | output: { 12 | filename: "bundle.[contenthash].js", 13 | path: resolve(__dirname, "../dist"), 14 | }, 15 | plugins: [ 16 | new CopyWebpackPlugin({ 17 | patterns: [{ from: resolve(__dirname, "../static") }], 18 | }), 19 | new HtmlWebpackPlugin({ 20 | template: resolve(__dirname, "../src/index.html"), 21 | minify: true, 22 | }), 23 | new MiniCSSExtractPlugin(), 24 | ], 25 | resolve: { 26 | modules: ["node_modules"], 27 | }, 28 | devtool: "source-map", 29 | module: { 30 | rules: [ 31 | // HTML 32 | { 33 | test: /\.(html)$/, 34 | use: ["html-loader"], 35 | }, 36 | 37 | // JS 38 | { 39 | test: /\.js$/, 40 | exclude: /node_modules/, 41 | use: ["babel-loader"], 42 | }, 43 | 44 | // CSS 45 | { 46 | test: /\.css$/, 47 | use: [MiniCSSExtractPlugin.loader, "css-loader"], 48 | }, 49 | 50 | // Images 51 | { 52 | test: /\.(jpg|png|gif|svg)$/, 53 | use: [ 54 | { 55 | loader: "file-loader", 56 | options: { 57 | outputPath: "assets/images/", 58 | }, 59 | }, 60 | ], 61 | }, 62 | 63 | // Fonts 64 | { 65 | test: /\.(ttf|eot|woff|woff2)$/, 66 | use: [ 67 | { 68 | loader: "file-loader", 69 | options: { 70 | outputPath: "assets/fonts/", 71 | }, 72 | }, 73 | ], 74 | }, 75 | { 76 | test: /\.(glsl|vs|fs|vert|frag)$/, 77 | exclude: /node_modules/, 78 | use: ["raw-loader", "glslify-loader"], 79 | }, 80 | ], 81 | }, 82 | }; 83 | -------------------------------------------------------------------------------- /bundler/webpack.dev.js: -------------------------------------------------------------------------------- 1 | import { merge } from "webpack-merge"; 2 | import commonConfiguration from "./webpack.common.js"; 3 | 4 | const PORT = 8080; 5 | 6 | export default merge(commonConfiguration, { 7 | mode: "development", 8 | devServer: { 9 | host: "localhost", 10 | port: PORT, 11 | static: "./dist", 12 | hot: true, 13 | open: true, 14 | https: true, 15 | allowedHosts: "all", 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /bundler/webpack.prod.js: -------------------------------------------------------------------------------- 1 | import { merge } from "webpack-merge"; 2 | import commonConfiguration from "./webpack.common.js"; 3 | import { CleanWebpackPlugin } from "clean-webpack-plugin"; 4 | 5 | export default merge(commonConfiguration, { 6 | mode: "production", 7 | plugins: [new CleanWebpackPlugin()], 8 | }); 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": "#", 3 | "license": "UNLICENSED", 4 | "type": "module", 5 | "scripts": { 6 | "build": "webpack --config ./bundler/webpack.prod.js", 7 | "dev": "webpack serve --config ./bundler/webpack.dev.js" 8 | }, 9 | "dependencies": { 10 | "@babel/core": "^7.13.1", 11 | "@babel/preset-env": "^7.13.5", 12 | "babel-loader": "^8.2.2", 13 | "clean-webpack-plugin": "^3.0.0", 14 | "copy-webpack-plugin": "^7.0.0", 15 | "css-loader": "^5.1.0", 16 | "dat.gui": "^0.7.7", 17 | "file-loader": "^6.2.0", 18 | "glsl-noise": "^0.0.0", 19 | "glslify-loader": "^2.0.0", 20 | "gsap": "^3.10.2", 21 | "html-loader": "^2.1.1", 22 | "html-webpack-plugin": "^5.2.0", 23 | "internal-ip": "^7.0.0", 24 | "portfinder-sync": "0.0.2", 25 | "postprocessing": "^6.25.0", 26 | "style-loader": "^2.0.0", 27 | "three": "^0.126.1", 28 | "webpack": "^5.24.2", 29 | "webpack-cli": "^4.5.0", 30 | "webpack-dev-server": "^4.7.4", 31 | "webpack-merge": "^5.7.3" 32 | }, 33 | "devDependencies": { 34 | "mini-css-extract-plugin": "^2.6.0", 35 | "raw-loader": "^4.0.2" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # 3D Particles Music Visualizer with MIDI controls in Three.js 2 | 3 | ![Screenshot](https://github.com/adarkforce/visual-performance/blob/master/readme/screenshot.png) 4 | 5 | ## Demo 6 | 7 | [![DEMO](https://i.imgur.com/BIYZKDg.png)](https://youtu.be/mkEiogFwOYM) 8 | 9 | 10 | ## Instructions 11 | 12 | - **Audio** 13 | - **Audio Input**: Select your audio input. 14 | - **Midi** 15 | - **MIDI Input**: Select your MIDI interface. 16 | - **MIDI Mapping** 17 | - **Storage** 18 | - **Mapping Name**: Choose a name for your current MIDI mapping preset to Save/Load. 19 | - **Load Mapping**: Load a MIDI mapping preset from storage with name corresponding to **Mapping Name**. 20 | - **Save Mapping**: Save the current MIDI mapping preset to storage with name corresponding to **Mapping Name**. 21 | - **Erase Mapping**: Erase a MIDI mapping preset from storage with name corresponding to **Mapping Name**. 22 | - **Saved Mappings**: A list with all the currently saved MIDI mappings. Click on one to load it. 23 | - **New Mapping**: 24 | - **Property**: The property of the particles to automate with the new mapping. 25 | - **Max Value**: The maximum value that the current mapping will reach. 26 | - **Min Value**: The minimum value that the current mapping will reach. 27 | - **Current Control**: The last listened midi control id received. Only Control Change Events are supported for now (Means knobs and faders usually). 28 | - **Start / End Mapping**: Toggle button to start midi mapping. When pressed start listening to midi messages, and when pressed a second time saves the midi mapping, with the associated particles parameter. 29 | - **Loaded Mappings**: Shows the list of mappings that have been loaded/created by the user. Clicking on a mapping removes it. 30 | - **Controls**: Manually control the particles parameters. 31 | 32 | ## Running locally 33 | 34 | ```bash 35 | # Install dependencies (only the first time) 36 | npm install 37 | 38 | # Run the local server at localhost:8080 39 | npm run dev 40 | 41 | # Build for production in the dist/ directory 42 | npm run build 43 | ``` 44 | -------------------------------------------------------------------------------- /readme/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adarkforce/3d-midi-audio-particles-threejs/2ac2eb850d507c138cb56750e9e11ccaf68b7a87/readme/screenshot.png -------------------------------------------------------------------------------- /src/audio-midi-particle-controller.js: -------------------------------------------------------------------------------- 1 | import { MidiControllerFactory, MidiMapper } from "./midi.js"; 2 | import { AudioManager as AudioInterfaceController } from "./audio.js"; 3 | import * as THREE from "three"; 4 | 5 | export class AudioMidiParticlesController { 6 | constructor(particles) { 7 | this.particles = particles; 8 | 9 | this.clock = new THREE.Clock(); 10 | 11 | this.params = { 12 | amplitude: 3, 13 | frequency: 0.01, 14 | maxDistance: 3, 15 | freq1: 60, 16 | freq2: 500, 17 | freq3: 6000, 18 | timeX: 2, 19 | timeY: 20, 20 | timeZ: 10, 21 | interpolation: 0.06, 22 | }; 23 | } 24 | 25 | static async create(particles) { 26 | const audioMidiParticlesBinder = new AudioMidiParticlesController( 27 | particles 28 | ); 29 | await audioMidiParticlesBinder.#setupAudioControls(); 30 | await audioMidiParticlesBinder.#setupMidiControls(); 31 | return audioMidiParticlesBinder; 32 | } 33 | 34 | async #setupAudioControls() { 35 | try { 36 | this.audioInterfaceController = new AudioInterfaceController(); 37 | this.audioDevices = await this.audioInterfaceController.getInputDevices(); 38 | this.audioInterfaceController.listenTo(this.audioDevices[0].deviceId); 39 | } catch (err) { 40 | console.log(err); 41 | } 42 | } 43 | 44 | async #setupMidiControls() { 45 | try { 46 | this.midiController = await MidiControllerFactory.createController(); 47 | const inputs = []; 48 | for (const [id, midiInput] of this.midiController.getInputs()) { 49 | inputs.push(midiInput); 50 | } 51 | const midiInterface = inputs[0]; 52 | if (!midiInterface) return; 53 | this.midiController.setActiveMidiInterface(midiInterface); 54 | this.midiMapper = new MidiMapper(this.midiController, this.params); 55 | this.midiAvailable = true; 56 | } catch (err) { 57 | console.log(err); 58 | this.midiAvailable = false; 59 | } 60 | } 61 | 62 | #hertzToIndex(hz) { 63 | return Math.floor( 64 | (hz * this.audioInterfaceController.analyser.frequencyBinCount) / 65 | (this.audioInterfaceController.context.sampleRate / 2) 66 | ); 67 | } 68 | 69 | #processAudio() { 70 | this.audioInterfaceController.updateAudioInfo(); 71 | 72 | const freq1Index = this.#hertzToIndex(this.params.freq1); 73 | const freq2Index = this.#hertzToIndex(this.params.freq2); 74 | const freq3Index = this.#hertzToIndex(this.params.freq3); 75 | 76 | const freqValue1 = this.audioInterfaceController.freqData[freq1Index]; 77 | this.frequencyValue1 = freqValue1 / 255; 78 | 79 | const freqValue2 = 80 | this.audioInterfaceController.freqData[Math.floor(freq2Index)]; 81 | this.frequencyValue2 = freqValue2 / 255; 82 | 83 | const freqValue3 = 84 | this.audioInterfaceController.freqData[Math.floor(freq3Index)]; 85 | this.frequencyValue3 = freqValue3 / 255; 86 | 87 | this.timeDomainValue = 88 | (128 - 89 | this.audioInterfaceController.timeDomainData[ 90 | Math.floor(this.audioInterfaceController.analyser.fftSize / 2) 91 | ]) / 92 | 127; 93 | } 94 | 95 | #updateParams() { 96 | let amplitude = this.params.amplitude; 97 | 98 | let frequency = this.params.frequency; 99 | 100 | let maxDistance = this.params.maxDistance - this.timeDomainValue; 101 | 102 | let timeX = this.params.timeX * this.frequencyValue1; 103 | 104 | let timeY = this.params.timeY * this.frequencyValue2; 105 | 106 | let timeZ = this.params.timeZ * this.frequencyValue3; 107 | 108 | let interpolation = this.params.interpolation; 109 | 110 | return { 111 | amplitude, 112 | frequency, 113 | timeX, 114 | timeY, 115 | timeZ, 116 | maxDistance, 117 | interpolation, 118 | }; 119 | } 120 | 121 | update() { 122 | const elapsedTime = this.clock.getElapsedTime(); 123 | const { 124 | amplitude, 125 | frequency, 126 | timeX, 127 | timeY, 128 | timeZ, 129 | maxDistance, 130 | interpolation, 131 | } = this.#updateParams(); 132 | this.#processAudio(); 133 | this.particles.setTimeElapsed(elapsedTime); 134 | this.particles.setAmplitude(amplitude); 135 | this.particles.setFrequency(frequency); 136 | this.particles.setMaxDistance(maxDistance); 137 | this.particles.setTimeMultiplierX(timeX); 138 | this.particles.setTimeMultiplierY(timeY); 139 | this.particles.setTimeMultiplierZ(timeZ); 140 | this.particles.setInterpolation(interpolation); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/audio.js: -------------------------------------------------------------------------------- 1 | export class AudioManager { 2 | constructor() { 3 | navigator.mediaDevices.getUserMedia({ audio: true }); 4 | this.context = new window.AudioContext(); 5 | this.analyser = this.context.createAnalyser(); 6 | this.analyser.smoothingTimeConstant = 0.7; 7 | this.analyser.fftSize = 2048; 8 | this.freqData = new Uint8Array(this.analyser.frequencyBinCount); 9 | this.timeDomainData = new Uint8Array(this.analyser.fftSize); 10 | document.addEventListener("click", async () => await this.resume()); 11 | document.addEventListener("scroll", async () => await this.resume()); 12 | } 13 | 14 | async resume() { 15 | if (this.context.state === "closed" || this.context.state === "suspended") { 16 | await this.context.resume(); 17 | } 18 | } 19 | 20 | async #registerStream(stream) { 21 | if (this.input) { 22 | this.input.disconnect(this.analyser); 23 | } 24 | this.input = this.context.createMediaStreamSource(stream); 25 | this.input.connect(this.analyser); 26 | await this.resume(); 27 | } 28 | async getInputDevices() { 29 | return (await navigator.mediaDevices.enumerateDevices()).filter( 30 | (d) => d.kind === "audioinput" 31 | ); 32 | } 33 | updateAudioInfo() { 34 | this.analyser.getByteFrequencyData(this.freqData); 35 | this.analyser.getByteTimeDomainData(this.timeDomainData); 36 | } 37 | 38 | async getOutputDevices() { 39 | return (await navigator.mediaDevices.enumerateDevices()).filter( 40 | (d) => d.kind === "audiooutput" 41 | ); 42 | } 43 | 44 | async listenTo(deviceId) { 45 | const stream = await navigator.mediaDevices.getUserMedia({ 46 | audio: { deviceId: { exact: deviceId } }, 47 | }); 48 | await this.#registerStream(stream); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/engine.js: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; 3 | import { Particles } from "./particles.js"; 4 | import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js"; 5 | import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js"; 6 | import * as dat from "dat.gui"; 7 | import { AudioMidiParticlesController } from "./audio-midi-particle-controller.js"; 8 | import { SMAAPass } from "three/examples/jsm/postprocessing/SMAAPass.js"; 9 | import { GUIAudio, GUIControls, GUIMidi } from "./gui.js"; 10 | import Stats from "three/examples/jsm/libs/stats.module.js"; 11 | export class Engine { 12 | constructor() { 13 | this._debug = false; 14 | this.sizes = { 15 | width: window.innerWidth, 16 | height: window.innerHeight, 17 | }; 18 | 19 | this.canvas = document.querySelector("canvas.webgl"); 20 | 21 | this.clock = new THREE.Clock(); 22 | 23 | this.#createCamera(); 24 | this.scene = new THREE.Scene(); 25 | this.scene.add(this.camera); 26 | 27 | this.#createLights(); 28 | this.#createRenderer(); 29 | this.#setupComposer(); 30 | this.#createParticles(); 31 | 32 | this.controls = new OrbitControls(this.camera, this.renderer.domElement); 33 | this.controls.enabled = false; 34 | this.stats = new Stats(); 35 | AudioMidiParticlesController.create(this.particles).then( 36 | (audioMidiParticlesController) => { 37 | this.audioMidiParticlesController = audioMidiParticlesController; 38 | this.#setupGUI(); 39 | } 40 | ); 41 | } 42 | 43 | #createParticles() { 44 | const tetraGeom = new THREE.TetrahedronBufferGeometry(15, 126); 45 | this.particles = new Particles(tetraGeom); 46 | this.scene.add(this.particles); 47 | this.particles.geometry.computeBoundingSphere(); 48 | const boundingSphere = this.particles.geometry.boundingSphere; 49 | this.camera.position.copy(boundingSphere.center.clone()); 50 | this.camera.position.z -= 30; 51 | } 52 | 53 | async #setupGUI() { 54 | this.gui = new dat.GUI(); 55 | this.guiAudio = new GUIAudio( 56 | this.gui, 57 | this.audioMidiParticlesController.audioInterfaceController 58 | ); 59 | this.guiMidi = new GUIMidi(this.gui, this.audioMidiParticlesController); 60 | this.guiControls = new GUIControls( 61 | this.gui, 62 | this.audioMidiParticlesController 63 | ); 64 | 65 | await this.guiAudio.init(); 66 | this.guiMidi.init(); 67 | this.guiControls.init(); 68 | 69 | if (this._debug) document.body.appendChild(this.stats.dom); 70 | this.gui.close(); 71 | } 72 | 73 | set debug(val) { 74 | this._debug = val; 75 | try { 76 | if (!this._debug) document.body.removeChild(this.stats.dom); 77 | } catch (err) {} 78 | } 79 | 80 | #createCamera() { 81 | this.camera = new THREE.PerspectiveCamera( 82 | 75, 83 | this.sizes.width / this.sizes.height, 84 | 0.1, 85 | 100 86 | ); 87 | } 88 | 89 | #createLights() { 90 | const pointLight = new THREE.PointLight(0xffffff, 0.1); 91 | pointLight.position.x = 2; 92 | pointLight.position.y = 3; 93 | pointLight.position.z = 4; 94 | this.scene.add(pointLight); 95 | } 96 | 97 | #setupComposer() { 98 | this.composer = new EffectComposer(this.renderer); 99 | 100 | this.composer.addPass(new RenderPass(this.scene, this.camera)); 101 | 102 | const smaaPass = new SMAAPass( 103 | this.sizes.width * this.renderer.getPixelRatio(), 104 | this.sizes.height * this.renderer.getPixelRatio() 105 | ); 106 | this.composer.addPass(smaaPass); 107 | //this.composer.addPass(new ShaderPass(GammaCorrectionShader)); 108 | 109 | this.composer.setSize(this.sizes.width, this.sizes.height); 110 | this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); 111 | } 112 | 113 | #createRenderer() { 114 | this.renderer = new THREE.WebGLRenderer({ 115 | canvas: this.canvas, 116 | powerPreference: "high-performance", 117 | }); 118 | this.renderer.setSize(this.sizes.width, this.sizes.height); 119 | this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); 120 | window.addEventListener("resize", () => { 121 | // Update sizes 122 | this.sizes.width = window.innerWidth; 123 | this.sizes.height = window.innerHeight; 124 | 125 | // Update camera 126 | this.camera.aspect = this.sizes.width / this.sizes.height; 127 | this.camera.updateProjectionMatrix(); 128 | 129 | // Update renderer 130 | this.renderer.setSize(this.sizes.width, this.sizes.height); 131 | this.composer.setSize(this.sizes.width, this.sizes.height); 132 | this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); 133 | this.composer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); 134 | }); 135 | } 136 | 137 | tick(elapsedTime) { 138 | //update midi audio and particles 139 | if (this.audioMidiParticlesController) { 140 | this.audioMidiParticlesController.update(); 141 | const center = this.particles.geometry.boundingSphere.center; 142 | this.camera.lookAt(center); 143 | this.particles.rotateY( 144 | (this.audioMidiParticlesController.timeDomainValue - 128) / 5000 145 | ); 146 | } 147 | 148 | this.controls.update(); 149 | 150 | if (this.stats && !this._debug) this.stats.update(); 151 | 152 | this.composer.render(); 153 | } 154 | 155 | run() { 156 | const _tick = () => { 157 | const elapsedTime = this.clock.getElapsedTime(); 158 | this.tick(elapsedTime); 159 | this.loopHandler = requestAnimationFrame(() => _tick()); 160 | }; 161 | 162 | _tick(); 163 | } 164 | 165 | stop() { 166 | if (this.loopHandler) { 167 | cancelAnimationFrame(this.loopHandler); 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/gui.js: -------------------------------------------------------------------------------- 1 | export class GUIControls { 2 | constructor(gui, audioMidiParticlesController) { 3 | this.gui = gui; 4 | this.audioMidiParticlesController = audioMidiParticlesController; 5 | } 6 | init() { 7 | this.controllerFolder = this.gui.addFolder("Controls"); 8 | this.controllerFolder 9 | .add(this.audioMidiParticlesController.params, "amplitude") 10 | .min(0.01) 11 | .max(5) 12 | .step(0.001) 13 | .listen() 14 | .updateDisplay(); 15 | 16 | this.controllerFolder 17 | .add(this.audioMidiParticlesController.params, "frequency") 18 | .min(0.01) 19 | .max(2) 20 | .step(0.001) 21 | .listen() 22 | .updateDisplay(); 23 | 24 | this.controllerFolder 25 | .add(this.audioMidiParticlesController.params, "maxDistance") 26 | .min(0.1) 27 | .max(20) 28 | .step(0.1) 29 | .listen() 30 | .updateDisplay(); 31 | 32 | this.controllerFolder 33 | .add(this.audioMidiParticlesController.params, "interpolation") 34 | .min(0.001) 35 | .max(1) 36 | .step(0.001) 37 | .listen() 38 | .updateDisplay(); 39 | 40 | this.controllerFolder 41 | .add(this.audioMidiParticlesController.params, "timeX") 42 | .min(0.01) 43 | .max(20) 44 | .step(0.001) 45 | .listen() 46 | .updateDisplay(); 47 | 48 | this.controllerFolder 49 | .add(this.audioMidiParticlesController.params, "timeY") 50 | .min(0.01) 51 | .max(20) 52 | .step(0.001) 53 | .listen() 54 | .updateDisplay(); 55 | 56 | this.controllerFolder 57 | .add(this.audioMidiParticlesController.params, "timeZ") 58 | .min(0.01) 59 | .max(20) 60 | .step(0.001) 61 | .listen() 62 | .updateDisplay(); 63 | } 64 | } 65 | 66 | export class GUIMidi { 67 | constructor(gui, audioMidiParticlesController) { 68 | this.gui = gui; 69 | this.audioMidiParticlesController = audioMidiParticlesController; 70 | this.midiManager = this.audioMidiParticlesController.midiController; 71 | this.midiMapper = this.audioMidiParticlesController.midiMapper; 72 | 73 | this.currentMappingsGUIControllers = []; 74 | this.savedMappingsGUIControllers = []; 75 | 76 | this.mappingState = { 77 | property: "", 78 | maxValue: 1, 79 | minValue: 0, 80 | mappingStarted: false, 81 | currentControl: "", 82 | mappingName: "", 83 | }; 84 | if (this.midiMapper) 85 | this.midiMapper.onCurrentMidiMessageChange((midiMessage) => { 86 | this.mappingState.currentControl = midiMessage.controlId; 87 | }); 88 | } 89 | #updateCurrentMidiMappings = () => { 90 | this.currentMappingsGUIControllers.forEach((controller) => 91 | controller.remove() 92 | ); 93 | this.currentMappingsGUIControllers = []; 94 | const _this = this; 95 | for (const mapping of this.midiMapper.midiMappings) { 96 | const name = `${mapping.midiMessage.controlId} -> ${mapping.property}`; 97 | const guiController = this.currentMappingFolder.add( 98 | { 99 | [name]: () => this.midiMapper.removeMapping(mapping), 100 | }, 101 | name 102 | ); 103 | guiController.onFinishChange(function () { 104 | _this.#updateCurrentMidiMappings(); 105 | }); 106 | this.currentMappingsGUIControllers.push(guiController); 107 | } 108 | }; 109 | 110 | #updateSavedMappings() { 111 | this.savedMappingsGUIControllers.forEach((controller) => 112 | controller.remove() 113 | ); 114 | this.savedMappingsGUIControllers = []; 115 | const savedMappings = this.midiMapper.getSavedMappingsList(); 116 | for (const savedMapping of savedMappings) { 117 | const guiController = this.savedMappingFolder 118 | .add( 119 | { 120 | [savedMapping.key]: () => { 121 | this.mappingState.mappingName = savedMapping.key; 122 | this.midiMapper.loadFromStorage(savedMapping.key); 123 | }, 124 | }, 125 | savedMapping.key 126 | ) 127 | .onFinishChange(() => { 128 | this.#updateCurrentMidiMappings(); 129 | this.currentMappingFolder.open(); 130 | }); 131 | this.savedMappingsGUIControllers.push(guiController); 132 | } 133 | if (savedMappings.length > 0) this.savedMappingFolder.open(); 134 | } 135 | 136 | #createNewMappingFolder(parent) { 137 | this.newMappingFolder = parent.addFolder("New Mapping"); 138 | this.newMappingFolder.add( 139 | this.mappingState, 140 | "property", 141 | Object.keys(this.audioMidiParticlesController.params) 142 | ); 143 | this.newMappingFolder 144 | .add(this.mappingState, "maxValue") 145 | .name("Max Value") 146 | .step(0.01); 147 | this.newMappingFolder 148 | .add(this.mappingState, "minValue") 149 | .name("Min Value") 150 | .step(0.01); 151 | this.newMappingFolder 152 | .add(this.mappingState, "currentControl") 153 | .name("Current Control") 154 | .listen(); 155 | 156 | const _this = this; 157 | 158 | this.newMappingFolder 159 | .add({ startMapping: () => {} }, "startMapping") 160 | .name("Start Mapping") 161 | .onFinishChange(function () { 162 | const startEndMappingButton = this; 163 | if (!_this.mappingState.mappingStarted) { 164 | const maxValue = _this.mappingState.maxValue; 165 | const minValue = _this.mappingState.minValue; 166 | _this.midiMapper.startMapping({ 167 | property: _this.mappingState.property, 168 | maxValue: maxValue, 169 | minValue: minValue, 170 | }); 171 | startEndMappingButton.name("End Mapping"); 172 | startEndMappingButton.updateDisplay(); 173 | } else { 174 | _this.midiMapper.endMapping(); 175 | startEndMappingButton.name("Start Mapping"); 176 | startEndMappingButton.updateDisplay(); 177 | } 178 | _this.mappingState.mappingStarted = !_this.mappingState.mappingStarted; 179 | _this.#updateCurrentMidiMappings(); 180 | }); 181 | } 182 | 183 | #createStorageFolder(parent) { 184 | this.storageFolder = parent.addFolder("Storage"); 185 | 186 | this.storageFolder 187 | .add(this.mappingState, "mappingName") 188 | .name("Mapping Name") 189 | .listen(); 190 | 191 | this.storageFolder 192 | .add( 193 | { 194 | load: () => 195 | this.midiMapper.loadFromStorage(this.mappingState.mappingName), 196 | }, 197 | "load" 198 | ) 199 | .name("Load Mapping") 200 | .onFinishChange(() => { 201 | this.#updateCurrentMidiMappings(); 202 | this.currentMappingFolder.open(); 203 | }); 204 | 205 | this.storageFolder 206 | .add( 207 | { 208 | save: () => 209 | this.midiMapper.saveToStorage(this.mappingState.mappingName), 210 | }, 211 | "save" 212 | ) 213 | .name("Save Mapping") 214 | .onFinishChange(() => this.#updateSavedMappings()); 215 | 216 | this.storageFolder 217 | .add( 218 | { 219 | erase: () => 220 | this.midiMapper.eraseFromStorage(this.mappingState.mappingName), 221 | }, 222 | "erase" 223 | ) 224 | .name("Erase Mapping") 225 | .onFinishChange(() => this.#updateSavedMappings()); 226 | } 227 | 228 | #createMidiFolder(parent) { 229 | const midiInputs = this.midiManager.getInputs(); 230 | const midiOptions = []; 231 | for (let input of midiInputs) { 232 | midiOptions.push(input[1].name); 233 | } 234 | this.midiFolder = parent.addFolder("MIDI"); 235 | this.midiFolder 236 | .add(this.midiManager.midiInterface, "name") 237 | .options(midiOptions) 238 | .name("MIDI Input") 239 | .onFinishChange((val) => { 240 | for (const [id, midiInput] of this.midiController.getInputs()) { 241 | if (midiInput.name === val) { 242 | this.midiManager.setActiveMidiInterface(midiInput); 243 | } 244 | } 245 | }); 246 | } 247 | 248 | #createMappingFolder(parent) { 249 | this.mappingFolder = parent.addFolder("MIDI Mapping"); 250 | this.mappingFolder.open(); 251 | } 252 | 253 | #createSavedMappingsFolder(parent) { 254 | this.savedMappingFolder = parent.addFolder("Saved Mappings"); 255 | this.#updateSavedMappings(); 256 | } 257 | 258 | #createCurrentMappingsFolder(parent) { 259 | this.currentMappingFolder = parent.addFolder("Loaded Mappings"); 260 | this.#updateCurrentMidiMappings(); 261 | } 262 | 263 | init() { 264 | if ( 265 | !this.midiManager || 266 | !this.midiMapper || 267 | this.audioMidiParticlesController.midiAvailable !== true 268 | ) 269 | return; 270 | 271 | const firstPropertyKey = Object.keys( 272 | this.audioMidiParticlesController.params 273 | )[0]; 274 | 275 | this.mappingState.property = firstPropertyKey; 276 | 277 | this.#createMidiFolder(this.gui); 278 | 279 | this.#createMappingFolder(this.midiFolder); 280 | 281 | this.#createStorageFolder(this.mappingFolder); 282 | 283 | this.#createSavedMappingsFolder(this.mappingFolder); 284 | 285 | this.#createNewMappingFolder(this.mappingFolder); 286 | 287 | this.#createCurrentMappingsFolder(this.mappingFolder); 288 | } 289 | } 290 | 291 | export class GUIAudio { 292 | constructor(gui, audioInterfaceController) { 293 | this.gui = gui; 294 | this.audioInterfaceController = audioInterfaceController; 295 | this.state = { 296 | audioSelected: "", 297 | }; 298 | } 299 | async init() { 300 | const audioDevices = await this.audioInterfaceController.getInputDevices(); 301 | if (audioDevices.length === 0) return; 302 | this.state.audioSelected = audioDevices[0].label; 303 | this.audioFolder = this.gui.addFolder("Audio"); 304 | this.audioFolder 305 | .add(this.state, "audioSelected") 306 | .options(audioDevices.map((d) => d.label)) 307 | .name("Audio Input") 308 | .onFinishChange(async (val) => { 309 | const selectedDevice = audioDevices.filter((d) => d.label === val)[0]; 310 | this.audioInterfaceController.listenTo(selectedDevice.deviceId); 311 | }); 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | MIDI/Audio Particles 7 | 8 | 9 |
10 |

Click to start.

11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /src/midi.js: -------------------------------------------------------------------------------- 1 | export const MIDI_EVENTS = { 2 | CONTROLLER: 176, 3 | NOTE_ON: 144, 4 | NOTE_OFF: 128, 5 | }; 6 | 7 | export const MIDI_MESSAGE_TYPE = { 8 | CONTROLLER: "controllerChange", 9 | NOTE_ON: "nodeOn", 10 | NOTE_OFF: "nodeOff", 11 | }; 12 | 13 | class MidiMapping { 14 | constructor({ property, midiMessage, maxValue, minValue }) { 15 | this.property = property; 16 | this.midiMessage = midiMessage; 17 | this.maxValue = maxValue; 18 | this.minValue = minValue; 19 | } 20 | } 21 | 22 | export class MidiMapper { 23 | constructor(midiController, targetObject) { 24 | this.midiController = midiController; 25 | this.targetObject = targetObject; 26 | this.currentMidiMessage; 27 | this.midiMappings = []; 28 | this.currentMidiMapping = null; 29 | this.onCurrentMidiMessageChangeCallback = () => {}; 30 | this.midiController.addMidiMessageListener((message) => { 31 | this.currentMidiMessage = message; 32 | this.#updateValueFromMessage(message); 33 | this.onCurrentMidiMessageChangeCallback(this.currentMidiMessage); 34 | }); 35 | } 36 | 37 | onCurrentMidiMessageChange(callback) { 38 | this.onCurrentMidiMessageChangeCallback = callback; 39 | } 40 | 41 | #updateValueFromMessage(message) { 42 | for (let i = 0; i < this.midiMappings.length; i++) { 43 | if ( 44 | this.midiMappings[i].midiMessage.controlId && 45 | this.midiMappings[i].midiMessage.controlId === message.controlId 46 | ) { 47 | const key = this.midiMappings[i].property; 48 | const minVal = this.midiMappings[i].minValue; 49 | const maxVal = this.midiMappings[i].maxValue; 50 | const x = message.value / 128; 51 | const scaledValue = x * (maxVal - minVal) + minVal; 52 | this.targetObject[key] = scaledValue; 53 | } 54 | } 55 | } 56 | 57 | getSavedMappingsList() { 58 | let keyIndex = 0; 59 | const savedMappings = []; 60 | while (localStorage.key(keyIndex) !== null) { 61 | const key = localStorage.key(keyIndex); 62 | const mapping = localStorage.getItem(key); 63 | if (mapping !== null) { 64 | savedMappings.push({ key, mapping: JSON.parse(mapping) }); 65 | } 66 | keyIndex++; 67 | } 68 | return savedMappings; 69 | } 70 | 71 | saveToStorage(name) { 72 | localStorage.setItem(name, JSON.stringify(this.midiMappings)); 73 | } 74 | 75 | loadFromStorage(name) { 76 | const midiMappings = JSON.parse(localStorage.getItem(name)); 77 | if (midiMappings) this.midiMappings = midiMappings; 78 | else console.warn("cannot load midi mapping..."); 79 | } 80 | 81 | eraseFromStorage(name) { 82 | localStorage.removeItem(name); 83 | } 84 | 85 | removeMapping(mapping) { 86 | this.midiMappings = this.midiMappings.filter((m) => { 87 | if ( 88 | m.property === mapping.property && 89 | m.midiMessage.controlId === mapping.midiMessage.controlId 90 | ) { 91 | return false; 92 | } else { 93 | return true; 94 | } 95 | }); 96 | } 97 | 98 | startMapping({ property, maxValue = 1, minValue = 0 }) { 99 | this.currentMidiMapping = new MidiMapping({ 100 | property, 101 | maxValue, 102 | minValue, 103 | }); 104 | } 105 | 106 | endMapping() { 107 | if (!this.currentMidiMapping || !this.currentMidiMessage) return; 108 | const tempMidiMapping = this.currentMidiMapping; 109 | tempMidiMapping.midiMessage = this.currentMidiMessage; 110 | this.removeMapping(tempMidiMapping); 111 | this.currentMidiMapping.midiMessage = this.currentMidiMessage; 112 | this.midiMappings.push(this.currentMidiMapping); 113 | } 114 | } 115 | 116 | class MidiController { 117 | constructor(midi) { 118 | this.midi = midi; 119 | this.controllerChangeCallbacks = []; 120 | this.onNoteOnCallbacks = []; 121 | this.onNoteOffCallbacks = []; 122 | this.midiMessageListeners = []; 123 | this.midi.onstatechange = this.#handleMidiStateChange.bind(this); 124 | this.onMidiStateChangeCallbacks = []; 125 | this.midiInterface = { 126 | name: "", 127 | }; 128 | } 129 | 130 | #handleMidiStateChange(event) { 131 | this.onMidiStateChangeCallbacks.forEach((c) => c(event)); 132 | } 133 | 134 | addMidiStateChangeListener(callback) { 135 | this.onMidiStateChangeCallbacks.push(callback); 136 | } 137 | 138 | parseMidiMessage(message) { 139 | const data = message.data; 140 | 141 | switch (data[0]) { 142 | case MIDI_EVENTS.CONTROLLER: { 143 | return { 144 | type: MIDI_MESSAGE_TYPE.CONTROLLER, 145 | controlId: data[1], 146 | value: data[2], 147 | }; 148 | } 149 | case MIDI_EVENTS.NOTE_ON: { 150 | return { 151 | type: MIDI_MESSAGE_TYPE.NOTE_ON, 152 | note: data[1], 153 | velocity: data[2], 154 | }; 155 | } 156 | case MIDI_EVENTS.NOTE_OFF: { 157 | return { 158 | type: MIDI_MESSAGE_TYPE.NOTE_OFF, 159 | note: data[1], 160 | }; 161 | } 162 | default: { 163 | return { 164 | type: data[0], 165 | data1: data[1], 166 | data2: data[2], 167 | }; 168 | } 169 | } 170 | } 171 | 172 | #handleMidiMessage(message) { 173 | const parsedMidiMessage = this.parseMidiMessage(message); 174 | 175 | switch (parsedMidiMessage.type) { 176 | case MIDI_MESSAGE_TYPE.CONTROLLER: { 177 | this.controllerChangeCallbacks.forEach((callback) => 178 | callback(parsedMidiMessage) 179 | ); 180 | break; 181 | } 182 | case MIDI_MESSAGE_TYPE.NOTE_ON: { 183 | this.onNoteOnCallbacks.forEach((callback) => { 184 | callback(parsedMidiMessage); 185 | }); 186 | break; 187 | } 188 | case MIDI_MESSAGE_TYPE.NOTE_OFF: { 189 | this.onNoteOffCallbacks.forEach((callback) => { 190 | callback(parsedMidiMessage); 191 | }); 192 | break; 193 | } 194 | default: { 195 | console.warn("unknown midi message", parsedMidiMessage); 196 | break; 197 | } 198 | } 199 | this.midiMessageListeners.forEach((listener) => 200 | listener(parsedMidiMessage) 201 | ); 202 | } 203 | 204 | addMidiMessageListener(callback) { 205 | this.midiMessageListeners.push(callback); 206 | } 207 | 208 | addControllerChangeListener(callback) { 209 | this.controllerChangeCallbacks.push(callback); 210 | } 211 | 212 | addNoteOnListener(callback) { 213 | this.onNoteOnCallbacks.push(callback); 214 | } 215 | 216 | addNoteOffListener(callback) { 217 | this.onNoteOffCallbacks.push(callback); 218 | } 219 | 220 | setActiveMidiInterface(midiInterface) { 221 | this.midiInterface = midiInterface; 222 | this.midiInterface.onmidimessage = this.#handleMidiMessage.bind(this); 223 | } 224 | 225 | getInputs() { 226 | return this.midi.inputs; 227 | } 228 | getOutputs() { 229 | return this.midi.outputs; 230 | } 231 | } 232 | 233 | class MidiControllerFactoryImpl { 234 | createController() { 235 | return new Promise(async (resolve, reject) => { 236 | const onMIDIFailure = (msg) => { 237 | console.log("Failed to get MIDI access - " + msg); 238 | reject(msg); 239 | }; 240 | 241 | const onMidiSuccess = (midiAccess) => { 242 | resolve(new MidiController(midiAccess)); 243 | }; 244 | try { 245 | if (navigator.requestMIDIAccess) { 246 | const midiAccess = await navigator.requestMIDIAccess(); 247 | onMidiSuccess(midiAccess); 248 | } else { 249 | onMIDIFailure(); 250 | } 251 | } catch (err) { 252 | onMIDIFailure(err); 253 | } 254 | }); 255 | } 256 | } 257 | 258 | export const MidiControllerFactory = new MidiControllerFactoryImpl(); 259 | -------------------------------------------------------------------------------- /src/particles.js: -------------------------------------------------------------------------------- 1 | import * as THREE from "three"; 2 | import vertex from "./shaders/vertex.glsl"; 3 | import fragment from "./shaders/fragment.glsl"; 4 | 5 | export class Particles extends THREE.Object3D { 6 | constructor(geometry) { 7 | super(); 8 | this.geometry = geometry; 9 | 10 | this.textureLoader = new THREE.TextureLoader(); 11 | 12 | this.material = new THREE.ShaderMaterial({ 13 | extensions: { 14 | derivatives: "#extensons GL_OES_standard_derivatives : enable", 15 | }, 16 | transparent: true, 17 | uniforms: { 18 | time: { value: 0 }, 19 | frequency: { value: 1.0 }, 20 | amplitude: { value: 0.5 }, 21 | maxDistance: { value: 1.0 }, 22 | timeX: { value: 0.05 }, 23 | timeY: { value: 0.05 }, 24 | timeZ: { value: 0.05 }, 25 | uNoiseTexture: { value: null }, 26 | diffuse: { value: new THREE.Color(0xffffff) }, 27 | opacity: { value: 0.1 }, 28 | interpolation: { value: 0.1 }, 29 | }, 30 | blending: THREE.AdditiveBlending, 31 | vertexShader: vertex, 32 | fragmentShader: fragment, 33 | }); 34 | this.points = new THREE.Points(this.geometry, this.material); 35 | this.add(this.points); 36 | 37 | this.updateMatrixWorld(); 38 | 39 | this.#loadTextures(); 40 | } 41 | 42 | #loadTextures() { 43 | this.textureLoader.load("perlin.jpeg", (texture) => { 44 | texture.wrapS = THREE.RepeatWrapping; 45 | texture.wrapT = THREE.RepeatWrapping; 46 | texture.repeat.set(1, 1); 47 | this.material.uniforms.uNoiseTexture.value = texture; 48 | texture.needsUpdate = true; 49 | }); 50 | } 51 | 52 | setInterpolation(interpolation) { 53 | this.material.uniforms.interpolation.value = interpolation; 54 | } 55 | 56 | setTimeElapsed(time) { 57 | this.material.uniforms.time.value = time; 58 | } 59 | 60 | setFrequency(freq) { 61 | this.material.uniforms.frequency.value = freq; 62 | } 63 | 64 | setAmplitude(amp) { 65 | this.material.uniforms.amplitude.value = amp; 66 | } 67 | 68 | setTimeMultiplierX(timeX) { 69 | this.material.uniforms.timeX.value = timeX; 70 | } 71 | 72 | setTimeMultiplierY(timeY) { 73 | this.material.uniforms.timeY.value = timeY; 74 | } 75 | 76 | setTimeMultiplierZ(timeZ) { 77 | this.material.uniforms.timeZ.value = timeZ; 78 | } 79 | 80 | setMaxDistance(maxDist) { 81 | this.material.uniforms.maxDistance.value = maxDist; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/script.js: -------------------------------------------------------------------------------- 1 | import "./style.css"; 2 | import { Engine } from "./engine.js"; 3 | import { gsap } from "gsap"; 4 | 5 | const canvas = document.querySelector(".webgl"); 6 | const overlayElement = document.getElementById("overlay"); 7 | const textOverlayElement = document.getElementById("click-to-start"); 8 | 9 | gsap.fromTo(textOverlayElement, { opacity: 0 }, { opacity: 0.5, duration: 1 }); 10 | 11 | canvas.style.filter = "blur(4px)"; 12 | overlayElement.addEventListener("click", function () { 13 | gsap.to(canvas, { filter: "blur(0px)", duration: 1 }); 14 | gsap.to(overlayElement, { 15 | opacity: 0, 16 | duration: 1, 17 | onComplete: () => { 18 | overlayElement.style.display = "none"; 19 | }, 20 | }); 21 | }); 22 | 23 | const engine = new Engine(); 24 | engine.debug = false; 25 | engine.run(); 26 | -------------------------------------------------------------------------------- /src/shaders/fragment.glsl: -------------------------------------------------------------------------------- 1 | uniform float time; 2 | uniform vec3 diffuse; 3 | uniform float opacity; 4 | 5 | 6 | 7 | void main() { 8 | 9 | float d = length(2.0 * gl_PointCoord - 1.0); 10 | if (d > 1.0) { 11 | discard; 12 | } 13 | 14 | gl_FragColor = vec4( vec3(1.0), opacity); 15 | 16 | } -------------------------------------------------------------------------------- /src/shaders/vertex.glsl: -------------------------------------------------------------------------------- 1 | 2 | #pragma glslify: snoise2 = require(glsl-noise/simplex/2d); 3 | 4 | uniform float time; 5 | uniform float amplitude; 6 | uniform float frequency; 7 | uniform float maxDistance; 8 | uniform float interpolation; 9 | 10 | uniform float timeX; 11 | uniform float timeY; 12 | uniform float timeZ; 13 | uniform sampler2D uNoiseTexture; 14 | 15 | vec3 curl(vec3 pos) { 16 | float x = pos.x; 17 | float y = pos.y; 18 | float z = pos.z; 19 | 20 | float eps = 1., eps2 = 2. * eps; 21 | float n1, n2, a, b; 22 | 23 | x += time * .05; 24 | y += time * .05; 25 | z += time * .05; 26 | 27 | vec3 curl = vec3(0.); 28 | 29 | n1 = snoise2(vec2( x, y + eps )); 30 | n2 = snoise2(vec2( x, y - eps )); 31 | a = (n1 - n2)/eps2; 32 | 33 | n1 = snoise2(vec2( x, z + eps)); 34 | n2 = snoise2(vec2( x, z - eps)); 35 | b = (n1 - n2)/eps2; 36 | 37 | curl.x = a - b; 38 | 39 | n1 = snoise2(vec2( y, z + eps)); 40 | n2 = snoise2(vec2( y, z - eps)); 41 | a = (n1 - n2)/eps2; 42 | 43 | n1 = snoise2(vec2( x + eps, z)); 44 | n2 = snoise2(vec2( x + eps, z)); 45 | b = (n1 - n2)/eps2; 46 | 47 | curl.y = a - b; 48 | 49 | n1 = snoise2(vec2( x + eps, y)); 50 | n2 = snoise2(vec2( x - eps, y)); 51 | a = (n1 - n2)/eps2; 52 | 53 | n1 = snoise2(vec2( y + eps, z)); 54 | n2 = snoise2(vec2( y - eps, z)); 55 | b = (n1 - n2)/eps2; 56 | 57 | curl.z = a - b; 58 | 59 | return curl; 60 | } 61 | 62 | void main() { 63 | 64 | 65 | vec4 mvPosition = viewMatrix * modelMatrix * vec4(position, 1.0); 66 | 67 | vec3 newpos = position; 68 | 69 | vec4 noiseTextel = texture2D( uNoiseTexture, vec2(mod(uv.x + time * 0.05, 1.), mod(uv.y + time * 0.05, 1.)) - 0.5); 70 | vec4 noiseTextel2 = texture2D( uNoiseTexture, uv); 71 | 72 | float amp = amplitude + noiseTextel.r * .3; 73 | amp = mix(amp, amp * timeX * .001, .75) + timeX; 74 | 75 | float freq = frequency + noiseTextel2.r * .1; 76 | 77 | float freqPosX = freq * newpos.x; 78 | freqPosX = mix(freqPosX, freqPosX * timeX, interpolation); 79 | 80 | float freqPosY = freq * newpos.y; 81 | freqPosY = mix(freqPosY, freqPosY * timeY, interpolation); 82 | 83 | float freqPosZ = freq * newpos.z; 84 | freqPosZ = mix(freqPosZ, freqPosZ * timeZ, interpolation); 85 | 86 | vec3 target = newpos + curl(vec3(freqPosX, freqPosY, freqPosZ )) * amp; 87 | 88 | float d = length( position - target ) / maxDistance; 89 | 90 | target = mod(target, 2. * noiseTextel.r); 91 | 92 | newpos = mix( position, target, pow(d, 3.) ); 93 | 94 | mvPosition = modelViewMatrix * vec4(newpos, 1.0); 95 | 96 | gl_Position = projectionMatrix * mvPosition; 97 | 98 | gl_PointSize = 30. * (1. / - mvPosition.z); 99 | 100 | 101 | 102 | } -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@100&display=swap'); 2 | 3 | * 4 | { 5 | margin: 0; 6 | padding: 0; 7 | } 8 | 9 | html, 10 | body 11 | { 12 | height: 100vh; 13 | font-family: 'Poppins'; 14 | } 15 | 16 | .webgl 17 | { 18 | position: fixed; 19 | top: 0; 20 | left: 0; 21 | outline: none; 22 | } 23 | 24 | #overlay { 25 | background-color: rgba(0,0,0,.5); 26 | width: 100%; 27 | height: 100%; 28 | top: 0; 29 | left: 0; 30 | position: fixed; 31 | z-index: 1; 32 | cursor: pointer; 33 | display: flex; 34 | align-items: center; 35 | justify-content: center; 36 | } 37 | 38 | #click-to-start { 39 | color: white; 40 | font-family: 'Roboto', sans-serif; 41 | } -------------------------------------------------------------------------------- /static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adarkforce/3d-midi-audio-particles-threejs/2ac2eb850d507c138cb56750e9e11ccaf68b7a87/static/.gitkeep -------------------------------------------------------------------------------- /static/perlin.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adarkforce/3d-midi-audio-particles-threejs/2ac2eb850d507c138cb56750e9e11ccaf68b7a87/static/perlin.jpeg --------------------------------------------------------------------------------