├── .babelrc ├── .gitignore ├── README.md ├── assets ├── linear-spread.png ├── linear.png ├── logo.png ├── nocturne-15.m4a └── zedd-stay.m4a ├── package.json ├── src ├── index.html ├── index.js ├── sketch.js └── utils.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "react", 4 | "es2015", 5 | "stage-0" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | *.log 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Circle [[demo](https://ccorcos.github.io/circle/)] 2 | 3 | This is an audio visualizer built with [p5.js](http://p5js.org/). 4 | 5 | I was jamming with a buddy one day and I thought it would be really cool to know what key he was playing just by looking at a visualizer connected to a microphone in a room. 6 | 7 | I realized that if I take the FFT of a signal and slice it up into concentric circles that line up with the notes of the diatonic scale, you can get a pretty nice representation of the noise in the room. 8 | 9 | After playing around with various parameters, I realized that this was a pretty cool music visualizer and that's pretty much what you see now. You can tweak various parameters in the sidebar: 10 | 11 | - you can toggle the mode between song / microphone. 12 | - you can toggle the view between polar and linear. 13 | - you can toggle whether the octaves overlap or are spread out. 14 | - you can toggle the grid that displays the notes. 15 | - you can change the sharpness of the amplitude. 16 | - you can change the gain. 17 | - you can change the color scheme—the colors are deterines by anchoring a hue and sweeping through HSL for each octave. 18 | - you can edit the opacity of each layer. 19 | 20 | One thing that can use improvement is the FFT granularity. I'm using the 2048 bit FFT out of the box, I think with a custom [script processor node](https://developer.mozilla.org/en-US/docs/Web/API/ScriptProcessorNode) you could probably build your own FFT that has much better granularity. 21 | 22 | Here are some fun examples for you—click on the images to open up the visualizers with those presets. 23 | 24 | [![](./assets/logo.png)](https://ccorcos.github.io/circle/?grid=true&mode=song&view=polar&overlap=true&sharpness=6.02&gain=1.00&hue=254.23&sweep=-28.53&radius=0.37&opacity=0.10) 25 | 26 | [![](./assets/linear-spread.png)](https://ccorcos.github.io/circle/?grid=true&mode=song&view=linear&overlap=false&sharpness=5.01&gain=1.00&hue=254.23&sweep=-36.65&radius=0.37&opacity=0.60) 27 | 28 | [![](./assets/linear.png)](https://ccorcos.github.io/circle/?grid=false&mode=song&view=linear&overlap=true&sharpness=3.13&gain=1.00&hue=254.23&sweep=-36.65&radius=0.37&opacity=0.60) 29 | 30 | -------------------------------------------------------------------------------- /assets/linear-spread.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccorcos/circle/7851a04e1a786663acdb0d357937c6e47e9226c3/assets/linear-spread.png -------------------------------------------------------------------------------- /assets/linear.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccorcos/circle/7851a04e1a786663acdb0d357937c6e47e9226c3/assets/linear.png -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccorcos/circle/7851a04e1a786663acdb0d357937c6e47e9226c3/assets/logo.png -------------------------------------------------------------------------------- /assets/nocturne-15.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccorcos/circle/7851a04e1a786663acdb0d357937c6e47e9226c3/assets/nocturne-15.m4a -------------------------------------------------------------------------------- /assets/zedd-stay.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ccorcos/circle/7851a04e1a786663acdb0d357937c6e47e9226c3/assets/zedd-stay.m4a -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "circle", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "start": "webpack-dev-server --content-base dist", 7 | "build": "webpack -p", 8 | "deploy": "git add -f dist && git commit -m 'deploy' && git push origin `git subtree split --prefix dist master`:gh-pages --force && git rm -r dist && git commit -m 'cleanup deploy'", 9 | "release": "npm run build && npm run deploy" 10 | }, 11 | "keywords": [], 12 | "author": "Chet Corcos (http://www.chetcorcos.com/)", 13 | "license": "ISC", 14 | "devDependencies": { 15 | "babel-core": "^6.24.0", 16 | "babel-loader": "^6.4.1", 17 | "babel-preset-es2015": "^6.24.0", 18 | "babel-preset-react": "^6.23.0", 19 | "babel-preset-stage-0": "^6.22.0", 20 | "favicons-webpack-plugin": "0.0.7", 21 | "file-loader": "^0.10.1", 22 | "html-webpack-plugin": "^2.28.0", 23 | "webpack": "^2.2.1", 24 | "webpack-dev-server": "^2.4.2" 25 | }, 26 | "dependencies": { 27 | "glamor": "^2.20.24", 28 | "p5": "^0.5.7", 29 | "react": "^15.4.2", 30 | "react-dom": "^15.4.2" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Circle 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import Sketch from "./sketch"; 4 | 5 | const root = document.createElement("div"); 6 | document.body.appendChild(root); 7 | 8 | ReactDOM.render(, root); 9 | -------------------------------------------------------------------------------- /src/sketch.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import p5 from "p5"; 3 | import "p5/lib/addons/p5.sound"; 4 | import { css } from "glamor"; 5 | import * as u from "./utils"; 6 | // the default song to play 7 | import zeddUrl from "../assets/zedd-stay.m4a"; 8 | 9 | css.global("html, body", { 10 | padding: 0, 11 | margin: 0, 12 | boxSizing: "border-box", 13 | backgroundColor: "rgb(51, 51, 51)", 14 | overflow: "hidden" 15 | }); 16 | 17 | const toolbarWidth = 150; 18 | const linearViewHeight = 400; 19 | 20 | const styles = { 21 | layout: css({ 22 | position: "absolute", 23 | top: 0, 24 | bottom: 0, 25 | left: 0, 26 | right: 0, 27 | display: "flex", 28 | flexDirection: "row" 29 | }), 30 | content: css({ 31 | flex: "1" 32 | }), 33 | toolbar: css({ 34 | position: "absolute", 35 | left: "100%", 36 | top: 0, 37 | bottom: 0, 38 | width: toolbarWidth, 39 | backgroundColor: "rgba(51, 51, 51, 0)", 40 | display: "flex", 41 | flexDirection: "column", 42 | justifyContent: "center", 43 | padding: 4, 44 | boxSizing: "border-box" 45 | }), 46 | show: css({ 47 | transform: "translateX(-150px)", 48 | transition: "transform 0.2s ease-out" 49 | }), 50 | hide: css({ 51 | transform: "translateX(0px)", 52 | transition: "transform 0.2s ease-in" 53 | }), 54 | button: css({ 55 | border: "none", 56 | outline: "none", 57 | background: "transparent", 58 | color: "white", 59 | cursor: "pointer", 60 | padding: "4px 8px", 61 | textAlign: "center" 62 | }), 63 | link: css({ 64 | fontFamily: "sans-serif", 65 | fontSize: 12, 66 | textDecoration: "none", 67 | fontWeight: "normal" 68 | }), 69 | dropping: css({ 70 | position: "absolute", 71 | top: 0, 72 | bottom: 0, 73 | left: 0, 74 | right: 0, 75 | backgroundColor: "rgba(255,255,255,0.4)", 76 | color: "white", 77 | textTransform: "uppercase", 78 | fontSize: "32", 79 | fontWeight: "bold", 80 | fontFamily: "sans-serif", 81 | display: "flex", 82 | justifyContent: "center", 83 | alignItems: "center" 84 | }), 85 | section: css({ 86 | borderStyle: "solid", 87 | borderColor: "white", 88 | borderWidth: 1, 89 | borderRadius: 3, 90 | color: "white", 91 | textAlign: "center", 92 | overflow: "hidden", 93 | display: "flex", 94 | flexDirection: "column", 95 | margin: "4px 0" 96 | }), 97 | title: css({ 98 | backgroundColor: "white", 99 | color: "rgb(51, 51, 51)", 100 | border: "none", 101 | outline: "none", 102 | cursor: "pointer", 103 | padding: "4px 8px", 104 | textAlign: "center" 105 | }) 106 | }; 107 | 108 | const Section = props => ( 109 |
110 | 113 | {props.children} 114 |
115 | ); 116 | 117 | const Button = props => ( 118 | 121 | ); 122 | 123 | // get the first audio file from a drop event 124 | const getFirstFile = event => { 125 | const dt = event.dataTransfer; 126 | if (dt.items) { 127 | // Use DataTransferItemList interface to access the file(s) 128 | for (let i = 0; i < dt.items.length; i++) { 129 | if (dt.items[i].kind == "file") { 130 | const f = dt.items[i].getAsFile(); 131 | if (/audio/.test(f.type)) { 132 | return f; 133 | } 134 | } 135 | } 136 | } else { 137 | // Use DataTransfer interface to access the file(s) 138 | for (let i = 0; i < dt.files.length; i++) { 139 | const f = dt.files[i]; 140 | if (/audio/.test(f.type)) { 141 | return f; 142 | } 143 | } 144 | } 145 | }; 146 | 147 | export default class Sketch extends React.PureComponent { 148 | constructor(props) { 149 | super(props); 150 | this.state = { 151 | // playing a song or listening from the mic 152 | mode: "song", 153 | // the type of visualizer to show: 'polar' or 'linear' 154 | view: "polar", 155 | // whether a song is playing 156 | playing: true, 157 | uploading: false, 158 | // some scrubbable options for all visualizers 159 | sharpness: 3, 160 | gain: 1, 161 | hue: 240, 162 | sweep: -10, 163 | opacity: 0.1, 164 | // scrubbable options only valid for polar visualizer 165 | radius: 0.8, 166 | // boolean options 167 | grid: false, 168 | overlap: true, 169 | // whatever field we're scrubbing on 170 | scrubbing: undefined, 171 | // show the toolbar when the user is moving the mouse 172 | showToolbar: false, 173 | // user is hoving a file over the screen 174 | dropping: false, 175 | fullscreen: false 176 | }; 177 | // scrubbable values with min and max ranges 178 | this.scrubbers = { 179 | sharpness: [1, 10], 180 | gain: [0.6, 3], 181 | hue: [0, 360], 182 | sweep: [-40, 40], 183 | radius: [0, 1], 184 | opacity: [0, 1] 185 | }; 186 | // params to save to the url 187 | this.params = [ 188 | "grid", 189 | "mode", 190 | "view", 191 | "overlap", 192 | "sharpness", 193 | "gain", 194 | "hue", 195 | "sweep", 196 | "radius", 197 | "opacity" 198 | ]; 199 | this.loadUrlParams(); 200 | this.setupFullScreenListener(); 201 | } 202 | // load and save params to the url 203 | componentWillUpdate(nextProps, nextState) { 204 | this.saveUrlParams(nextState); 205 | } 206 | saveUrlParams(state) { 207 | const params = this.params 208 | .map( 209 | name => 210 | typeof state[name] === "number" 211 | ? `${name}=${encodeURIComponent(state[name].toFixed(2))}` 212 | : `${name}=${encodeURIComponent(state[name])}` 213 | ) 214 | .join("&"); 215 | window.history.pushState({}, "circle", `?${params}`); 216 | } 217 | loadUrlParams() { 218 | if (window.location.search !== "") { 219 | const saved = window.location.search 220 | .slice(1) 221 | .split("&") 222 | .map(str => str.split("=")) 223 | .reduce( 224 | (obj, [name, str]) => { 225 | try { 226 | obj[name] = JSON.parse(str); 227 | } catch (e) { 228 | // load strings as they are 229 | obj[name] = str; 230 | } 231 | return obj; 232 | }, 233 | {} 234 | ); 235 | this.state = { ...this.state, ...saved }; 236 | } 237 | } 238 | setSongMode = () => { 239 | this.setState({ mode: "song", playing: true }, this.reload); 240 | }; 241 | setMicMode = () => { 242 | this.setState({ mode: "mic", playing: false }, this.reload); 243 | }; 244 | pauseSong = () => { 245 | this.setState({ playing: false }); 246 | }; 247 | playSong = () => { 248 | this.setState({ playing: true }); 249 | }; 250 | showUploadOverlay = () => { 251 | this.setState({ 252 | uploading: true 253 | }); 254 | }; 255 | hideUploadOverlay = () => { 256 | this.setState({ 257 | uploading: false, 258 | dropping: false 259 | }); 260 | }; 261 | renderModeSection() { 262 | if (this.state.mode === "mic") { 263 | return
; 264 | } else { 265 | const pauseButton =