├── .editorconfig ├── .eslintrc.js ├── .eslintrc.json ├── .gitignore ├── .npmignore ├── .travis.yml ├── README.md ├── index.js ├── jsdoc.template.hbs ├── package-lock.json ├── package.json ├── scripts └── commit-msg ├── src ├── audio-player.js ├── data-types.js ├── midi-transformer.js ├── midi-visualizer.js ├── renderers.js └── renderers │ ├── d3.js │ ├── three.js │ └── utils.js └── test ├── audio-player.spec.js ├── data-types.spec.js ├── fixtures ├── MIDIOkFormat1.mid └── minimal-valid-midi.mid ├── helpers.js ├── midi-transformer.spec.js ├── midi-visualizer.spec.js └── renderers ├── d3.spec.js ├── three.spec.js └── utils.spec.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | # Matches multiple files with brace expansion notation 12 | # Set default charset 13 | [*.{js}] 14 | charset = utf-8 15 | 16 | # Tab indentation (no size specified) 17 | [*.js] 18 | indent_style = tab 19 | 20 | # Matches the exact files either package.json or .travis.yml 21 | [{package.json,.travis.yml,.jshintrc}] 22 | indent_style = space 23 | indent_size = 2 24 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es6": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "rules": { 9 | "indent": [ 10 | 2, 11 | "tab" 12 | ], 13 | "linebreak-style": [ 14 | 2, 15 | "unix" 16 | ], 17 | "quotes": [ 18 | 2, 19 | "single" 20 | ], 21 | "semi": [ 22 | 2, 23 | "always" 24 | ] 25 | } 26 | }; -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "indent": [ 4 | 2, 5 | "tab" 6 | ], 7 | "quotes": [ 8 | 2, 9 | "single" 10 | ], 11 | "linebreak-style": [ 12 | 2, 13 | "unix" 14 | ], 15 | "semi": [ 16 | 2, 17 | "always" 18 | ] 19 | }, 20 | "env": { 21 | "browser": true 22 | }, 23 | "extends": "eslint:recommended" 24 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | *.swp 4 | *.swo 5 | *.js.map 6 | node_modules 7 | npm-debug.log 8 | dist 9 | coverage 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edhille/midi-visualizer/c23c1bd8d0983cba9fe95c0f85f3a790cb44da8b/.npmignore -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 8 4 | - 10 5 | after_success: 6 | - npm run ci-coverage 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # midi-visualizer 2 | 3 | [![Build Status](https://travis-ci.org/edhille/midi-visualizer.svg?branch=master)](https://travis-ci.org/edhille/midi-visualizer) 4 | [![Coverage Status](https://coveralls.io/repos/github/edhille/midi-visualizer/badge.svg?branch=master)](https://coveralls.io/github/edhille/midi-visualizer?branch=master) 5 | [![Dependency Status](https://david-dm.org/edhille/midi-visualizer.svg)](https://david-dm.org/edhille/midi-visualizer) 6 | 7 | A simple, functional-based midi visualization library 8 | 9 | ## Example 10 | 11 | ``` 12 | const initMidiVisualizer = import 'midi-visualizer'; 13 | const config = { 14 | window: window, 15 | root: document.getElementById('#my-root'), 16 | width: 500, 17 | height: 500, 18 | midi: { 19 | data: myFnToFetchMidiData() 20 | }, 21 | audio: { 22 | data: myFnToFetchAudioData() 23 | }, 24 | renderer: setupMyCustomRenderer() 25 | }; 26 | 27 | initMidiVisualizer(config).then((visualizer) => { 28 | const playingVisualizer = visualizer.play(); 29 | // all your other fun operations... 30 | }).catch((error) => console.error('Oh man, something bad happened:', error)); 31 | ``` 32 | 33 | ## API Reference 34 | 35 | 36 | 37 | ## midiVisualizer 38 | Monad managing visualization animation of midi data 39 | 40 | 41 | * [midiVisualizer](#module_midiVisualizer) 42 | * [~restart(playheadSec)](#module_midiVisualizer..restart) ⇒ 43 | * [~pause()](#module_midiVisualizer..pause) ⇒ 44 | * [~stop()](#module_midiVisualizer..stop) ⇒ 45 | * [~resize()](#module_midiVisualizer..resize) ⇒ 46 | 47 | 48 | 49 | ### midiVisualizer~restart(playheadSec) ⇒ 50 | put MidiVisualizer into "play" state 51 | 52 | **Kind**: inner method of [midiVisualizer](#module_midiVisualizer) 53 | **Returns**: MidiVisualizer 54 | 55 | | Param | Type | Description | 56 | | --- | --- | --- | 57 | | playheadSec | number | offset in seconds to start playback | 58 | 59 | 60 | 61 | ### midiVisualizer~pause() ⇒ 62 | put MidiVisualizer into "pause" state 63 | 64 | **Kind**: inner method of [midiVisualizer](#module_midiVisualizer) 65 | **Returns**: MidiVisualizer 66 | 67 | 68 | ### midiVisualizer~stop() ⇒ 69 | put MidiVisualizer into "stop" state 70 | 71 | **Kind**: inner method of [midiVisualizer](#module_midiVisualizer) 72 | **Returns**: MidiVisualizer 73 | 74 | 75 | ### midiVisualizer~resize() ⇒ 76 | handle resize of page MidiVisualizer is rendering into 77 | 78 | **Kind**: inner method of [midiVisualizer](#module_midiVisualizer) 79 | **Returns**: MidiVisualizer 80 | 81 | 82 | 83 | 84 | ## midi-visualizer : object 85 | **Kind**: global namespace 86 | 87 | 88 | ### midi-visualizer~initMidiVisualizer(config) ⇒ Promise(MidiVisualizer, Error) 89 | initializes MidiVisualizer monad 90 | 91 | **Kind**: inner method of [midi-visualizer](#midi-visualizer) 92 | **Returns**: Promise(MidiVisualizer, Error) - promise that fulfills with MidiVisualizer instance 93 | 94 | | Param | Type | Description | 95 | | --- | --- | --- | 96 | | config | object | configuration data to set up MidiVisualizer | 97 | | config.midi.data | UInt8Array | array of unsigned 8-bit integers representing Midi data | 98 | | config.audio.data | UInt8Array | array of unsigned 8-bit integers representing audio data | 99 | | config.window | Window | Window of page holding the player | 100 | | config.root | HTMLElement | HTMLElement that will be the root node of the visualizer | 101 | | config.render | Renderer | Renderer strategy to use | 102 | | config.width | number | the width of our canvans | 103 | | config.height | number | the height of our canvans | 104 | 105 | 106 | 107 | 108 | 109 | ## RenderUtils 110 | 111 | * [RenderUtils](#module_RenderUtils) 112 | * [~MAX_RAF_DELTA_MS](#module_RenderUtils..MAX_RAF_DELTA_MS) : number 113 | * [~play(state, player, renderFn, resumeFn)](#module_RenderUtils..play) ⇒ RendererState 114 | * [~pause(state)](#module_RenderUtils..pause) ⇒ RendererState 115 | * [~stop(state)](#module_RenderUtils..stop) ⇒ RendererState 116 | * [~transformEvents(state, trackTransforms, animEvents)](#module_RenderUtils..transformEvents) ⇒ Array.<RenderEvent> 117 | * [~mapEvents(state, midi, config)](#module_RenderUtils..mapEvents) ⇒ RendererState 118 | * [~maxNote(currentMaxNote, event)](#module_RenderUtils..maxNote) ⇒ number 119 | * [~minNote(currentMinNote, event)](#module_RenderUtils..minNote) ⇒ number 120 | * [~isNoteToggleEvent(event)](#module_RenderUtils..isNoteToggleEvent) ⇒ boolean 121 | * [~isNoteOnEvent(event)](#module_RenderUtils..isNoteOnEvent) ⇒ boolean 122 | * [~render(state, cleanupFn, rafFn, currentRunningEvents, renderEvents, nowMs)](#module_RenderUtils..render) ⇒ Array.<RenderEvent> 123 | 124 | 125 | 126 | ### RenderUtils~MAX_RAF_DELTA_MS : number 127 | **Kind**: inner constant of [RenderUtils](#module_RenderUtils) 128 | **Default**: 16 129 | 130 | 131 | ### RenderUtils~play(state, player, renderFn, resumeFn) ⇒ RendererState 132 | Put visualizer in "play" state (where audio player is playing and animations are running) 133 | 134 | **Kind**: inner method of [RenderUtils](#module_RenderUtils) 135 | **Returns**: RendererState - - new monad state 136 | 137 | | Param | Type | Description | 138 | | --- | --- | --- | 139 | | state | RendererState | current monad state | 140 | | player | [AudioPlayer](#AudioPlayer) | audio player used for audio playback we are syncing to | 141 | | renderFn | RenderUtils~render | callback for actual rendering | 142 | | resumeFn | RenderUtils~resume | callback for resuming playback after stopping | 143 | 144 | 145 | 146 | ### RenderUtils~pause(state) ⇒ RendererState 147 | Put visualizer in "paused" state (where audio player is paused and animations are not running) 148 | 149 | **Kind**: inner method of [RenderUtils](#module_RenderUtils) 150 | **Returns**: RendererState - - new monad state 151 | 152 | | Param | Type | Description | 153 | | --- | --- | --- | 154 | | state | RendererState | current monad state | 155 | 156 | 157 | 158 | ### RenderUtils~stop(state) ⇒ RendererState 159 | Put visualizer in "stopped" state (where audio player is stopped and animations are not running) 160 | 161 | **Kind**: inner method of [RenderUtils](#module_RenderUtils) 162 | **Returns**: RendererState - - new monad state 163 | 164 | | Param | Type | Description | 165 | | --- | --- | --- | 166 | | state | RendererState | current monad state | 167 | 168 | 169 | 170 | ### RenderUtils~transformEvents(state, trackTransforms, animEvents) ⇒ Array.<RenderEvent> 171 | Applies given track transforms to animation events 172 | 173 | **Kind**: inner method of [RenderUtils](#module_RenderUtils) 174 | **Returns**: Array.<RenderEvent> - array of transformed renderEvents 175 | 176 | | Param | Type | Description | 177 | | --- | --- | --- | 178 | | state | RendererState | state monad | 179 | | trackTransforms | Array.<function()> | callback functions (TODO: document) | 180 | | animEvents | Array.<AnimEvent> | given animation events to transform | 181 | 182 | 183 | 184 | ### RenderUtils~mapEvents(state, midi, config) ⇒ RendererState 185 | Map over given Midi data, transforming MidiEvents into RenderEvents 186 | 187 | **Kind**: inner method of [RenderUtils](#module_RenderUtils) 188 | **Returns**: RendererState - - new monad state 189 | 190 | | Param | Type | Description | 191 | | --- | --- | --- | 192 | | state | RendererState | current monad state | 193 | | midi | Midi | midi data to map to RenderEvents | 194 | | config | object | configuration data | 195 | 196 | 197 | 198 | ### RenderUtils~maxNote(currentMaxNote, event) ⇒ number 199 | Compare given note with note in given RenderEvent, returning whichever is larger 200 | 201 | **Kind**: inner method of [RenderUtils](#module_RenderUtils) 202 | **Returns**: number - - largest of two notes 203 | 204 | | Param | Type | Description | 205 | | --- | --- | --- | 206 | | currentMaxNote | number | value of current "max" note | 207 | | event | RenderEvent | RenderEvent containing note to compare | 208 | 209 | 210 | 211 | ### RenderUtils~minNote(currentMinNote, event) ⇒ number 212 | Compare given note with note in given RenderEvent, returning whichever is smaller 213 | 214 | **Kind**: inner method of [RenderUtils](#module_RenderUtils) 215 | **Returns**: number - - smallest of two notes 216 | 217 | | Param | Type | Description | 218 | | --- | --- | --- | 219 | | currentMinNote | number | value of current "min" note | 220 | | event | RenderEvent | RenderEvent containing note to compare | 221 | 222 | 223 | 224 | ### RenderUtils~isNoteToggleEvent(event) ⇒ boolean 225 | Predicate to test whether given RenderEvent is for a note on/off event 226 | 227 | **Kind**: inner method of [RenderUtils](#module_RenderUtils) 228 | **Returns**: boolean - - is it a note on/off event 229 | 230 | | Param | Type | Description | 231 | | --- | --- | --- | 232 | | event | RenderEvent | RenderEvent to test | 233 | 234 | 235 | 236 | ### RenderUtils~isNoteOnEvent(event) ⇒ boolean 237 | Predicate to test whether given RenderEvent is for a note on event 238 | 239 | **Kind**: inner method of [RenderUtils](#module_RenderUtils) 240 | **Returns**: boolean - - is it a note on event 241 | 242 | | Param | Type | Description | 243 | | --- | --- | --- | 244 | | event | RenderEvent | RenderEvent to test | 245 | 246 | 247 | 248 | ### RenderUtils~render(state, cleanupFn, rafFn, currentRunningEvents, renderEvents, nowMs) ⇒ Array.<RenderEvent> 249 | render function 250 | 251 | **Kind**: inner method of [RenderUtils](#module_RenderUtils) 252 | **Returns**: Array.<RenderEvent> - - active running render events for this render call 253 | 254 | | Param | Type | Description | 255 | | --- | --- | --- | 256 | | state | RendererState | monad state | 257 | | cleanupFn | function | callback to remove expired animation artifacts | 258 | | rafFn | function | RAF callback to do actual animation | 259 | | currentRunningEvents | Array.<RenderEvent> | RenderEvents currently being animated | 260 | | renderEvents | Array.<RenderEvent> | new RenderEvents to animate | 261 | | nowMs | number | current time in milliseconds | 262 | 263 | 264 | 265 | 266 | 267 | ## ThreeJsRenderer 268 | 269 | * [ThreeJsRenderer](#module_ThreeJsRenderer) 270 | * [~prepDOM(midi, config)](#module_ThreeJsRenderer..prepDOM) ⇒ ThreeJsRendererState 271 | * [~resize(state, dimension)](#module_ThreeJsRenderer..resize) ⇒ ThreeJsRendererState 272 | * [~cleanup(state, currentRunningEvents[, expiredEvents[)](#module_ThreeJsRenderer..cleanup) ⇒ undefined 273 | * [~generateReturnFn(midi, config)](#module_ThreeJsRenderer..generateReturnFn) ⇒ 274 | * [~generate(renderConfig, frameRenderer, cleanupFn)](#module_ThreeJsRenderer..generate) ⇒ ThreeJsRenderer~generateReturnFn 275 | * [~frameRenderCb](#module_ThreeJsRenderer..frameRenderCb) ⇒ 276 | 277 | 278 | 279 | ### ThreeJsRenderer~prepDOM(midi, config) ⇒ ThreeJsRendererState 280 | handles initialization of DOM for renderer 281 | 282 | **Kind**: inner method of [ThreeJsRenderer](#module_ThreeJsRenderer) 283 | 284 | | Param | Type | Description | 285 | | --- | --- | --- | 286 | | midi | Midi | Midi instance of song information | 287 | | config | object | configuration information | 288 | | config.window | Window | Window where rendering will take place | 289 | | config.root | HTMLElement | DOM Element that will hold render canvas | 290 | | dimension.width | number | width of the rendering area | 291 | | dimension.height | number | height of the renderering area | 292 | 293 | 294 | 295 | ### ThreeJsRenderer~resize(state, dimension) ⇒ ThreeJsRendererState 296 | deals with resizing of the browser window 297 | 298 | **Kind**: inner method of [ThreeJsRenderer](#module_ThreeJsRenderer) 299 | 300 | | Param | Type | Description | 301 | | --- | --- | --- | 302 | | state | ThreeJsRendererState | current renderer state | 303 | | dimension | object | dimensions of render area | 304 | | dimension.width | number | | 305 | | dimension.height | number | | 306 | 307 | 308 | 309 | ### ThreeJsRenderer~cleanup(state, currentRunningEvents[, expiredEvents[) ⇒ undefined 310 | removes any object from the scene 311 | 312 | **Kind**: inner method of [ThreeJsRenderer](#module_ThreeJsRenderer) 313 | 314 | | Param | Type | Description | 315 | | --- | --- | --- | 316 | | state | ThreeJsRendererState | current renderer state | 317 | | currentRunningEvents[ | RenderEvent | array of RenderEvents currently active | 318 | | expiredEvents[ | RenderEvent | array of RenderEvents that are no longer active and should be cleaned up | 319 | 320 | 321 | 322 | ### ThreeJsRenderer~generateReturnFn(midi, config) ⇒ 323 | function returned to user for creating instance of ThreeJsRenderer 324 | 325 | **Kind**: inner method of [ThreeJsRenderer](#module_ThreeJsRenderer) 326 | **Returns**: ThreeJsRenderer 327 | 328 | | Param | Type | Description | 329 | | --- | --- | --- | 330 | | midi | Midi | Midi data to be renderered | 331 | | config | object | configuration information | 332 | | config.window | Window | Window where rendering will take place | 333 | | config.root | HTMLElement | DOM Element that will hold render canvas | 334 | | dimension.width | number | width of the rendering area | 335 | | dimension.height | number | height of the renderering area | 336 | 337 | 338 | 339 | ### ThreeJsRenderer~generate(renderConfig, frameRenderer, cleanupFn) ⇒ ThreeJsRenderer~generateReturnFn 340 | generator to create ThreeJsRenderer 341 | 342 | **Kind**: inner method of [ThreeJsRenderer](#module_ThreeJsRenderer) 343 | 344 | | Param | Type | Description | 345 | | --- | --- | --- | 346 | | renderConfig | object | configuration information for setup | 347 | | frameRenderer | ThreeJsRenderer~frameRenderCb | callback for rendering events | 348 | | cleanupFn | [cleanupCb](#ThreeJsRenderer..cleanupCb) | callback for cleaning up THREEJS | 349 | 350 | 351 | 352 | ### ThreeJsRenderer~frameRenderCb ⇒ 353 | callback for actual rendering of frame 354 | 355 | **Kind**: inner typedef of [ThreeJsRenderer](#module_ThreeJsRenderer) 356 | **Returns**: undefined 357 | 358 | | Param | Type | Description | 359 | | --- | --- | --- | 360 | | eventsToAdd[ | ThreeJsRenderEvent | events that are queued up to be rendered in the next frame | 361 | | scene | THREEJS~Scene | ThreeJS scene events should be renderered in | 362 | | camera | THREEJS~Camera | ThreeJS camera for given scene | 363 | | THREE | THREEJS | ThreeJS | 364 | 365 | 366 | 367 | 368 | 369 | ## D3Renderer 370 | 371 | * [D3Renderer](#module_D3Renderer) 372 | * [~prepDOM(midi, config)](#module_D3Renderer..prepDOM) ⇒ D3RendererState 373 | * [~resize(state, dimension)](#module_D3Renderer..resize) ⇒ D3RendererState 374 | * [~generateReturnFn(midi, config)](#module_D3Renderer..generateReturnFn) ⇒ 375 | * [~generate(renderConfig)](#module_D3Renderer..generate) ⇒ D3Renderer 376 | 377 | 378 | 379 | ### D3Renderer~prepDOM(midi, config) ⇒ D3RendererState 380 | handles initialization of DOM for renderer 381 | 382 | **Kind**: inner method of [D3Renderer](#module_D3Renderer) 383 | 384 | | Param | Type | Description | 385 | | --- | --- | --- | 386 | | midi | Midi | Midi instance of song information | 387 | | config | object | configuration information | 388 | | config.window | Window | Window where rendering will take place | 389 | | config.root | HTMLElement | DOM Element that will hold render canvas | 390 | | dimension.width | number | width of the rendering area | 391 | | dimension.height | number | height of the renderering area | 392 | 393 | 394 | 395 | ### D3Renderer~resize(state, dimension) ⇒ D3RendererState 396 | deals with resizing of the browser window 397 | 398 | **Kind**: inner method of [D3Renderer](#module_D3Renderer) 399 | 400 | | Param | Type | Description | 401 | | --- | --- | --- | 402 | | state | D3RendererState | current renderer state | 403 | | dimension | object | dimensions of render area | 404 | | dimension.width | number | | 405 | | dimension.height | number | | 406 | 407 | 408 | 409 | ### D3Renderer~generateReturnFn(midi, config) ⇒ 410 | function returned to user for creating instance of D3Renderer 411 | 412 | **Kind**: inner method of [D3Renderer](#module_D3Renderer) 413 | **Returns**: D3Renderer 414 | 415 | | Param | Type | Description | 416 | | --- | --- | --- | 417 | | midi | Midi | Midi data to be renderered | 418 | | config | object | configuration information | 419 | | config.window | Window | Window where rendering will take place | 420 | | config.root | HTMLElement | DOM Element that will hold render canvas | 421 | | dimension.width | number | width of the rendering area | 422 | | dimension.height | number | height of the renderering area | 423 | 424 | 425 | 426 | ### D3Renderer~generate(renderConfig) ⇒ D3Renderer 427 | generator to create D3Renderer 428 | 429 | **Kind**: inner method of [D3Renderer](#module_D3Renderer) 430 | 431 | | Param | Type | Description | 432 | | --- | --- | --- | 433 | | renderConfig | object | configuration data for renderer | 434 | | renderConfig.frameRenderer | frameRenderCb | callback for rendering individual frames | 435 | 436 | 437 | 438 | 439 | 440 | ## AudioPlayer 441 | **Kind**: global class 442 | 443 | * [AudioPlayer](#AudioPlayer) 444 | * [new AudioPlayer(params)](#new_AudioPlayer_new) 445 | * _instance_ 446 | * [.getPlayheadTime()](#AudioPlayer+getPlayheadTime) ⇒ 447 | * [.play([startTimeOffset], [playheadSec])](#AudioPlayer+play) 448 | * [.pause(stopAfter)](#AudioPlayer+pause) 449 | * _static_ 450 | * [.getAudioContextFromWindow(window)](#AudioPlayer.getAudioContextFromWindow) ⇒ 451 | * _inner_ 452 | * [~loadDataCallback](#AudioPlayer..loadDataCallback) : function 453 | 454 | 455 | 456 | ### new AudioPlayer(params) 457 | manages audio playback 458 | 459 | **Returns**: AudioPlayer 460 | 461 | | Param | Type | Description | 462 | | --- | --- | --- | 463 | | params | object | settings for audio player | 464 | | params.window | Window | Window used to retrieve AudioContext | 465 | 466 | 467 | 468 | ### audioPlayer.getPlayheadTime() ⇒ 469 | gets the playhead time in milliseconds 470 | 471 | **Kind**: instance method of [AudioPlayer](#AudioPlayer) 472 | **Returns**: playheadTimeMs 473 | 474 | 475 | ### audioPlayer.play([startTimeOffset], [playheadSec]) 476 | initiates playing of audio 477 | 478 | **Kind**: instance method of [AudioPlayer](#AudioPlayer) 479 | 480 | | Param | Type | Default | Description | 481 | | --- | --- | --- | --- | 482 | | [startTimeOffset] | number | 0 | offset in seconds to wait before playing | 483 | | [playheadSec] | number | 0 | where to start playback within audio in seconds | 484 | 485 | 486 | 487 | ### audioPlayer.pause(stopAfter) 488 | pauses playing of audio 489 | 490 | **Kind**: instance method of [AudioPlayer](#AudioPlayer) 491 | 492 | | Param | Type | Description | 493 | | --- | --- | --- | 494 | | stopAfter | number | number of seconds to wait before stopping | 495 | 496 | 497 | 498 | ### AudioPlayer.getAudioContextFromWindow(window) ⇒ 499 | cross-browser fetch of AudioContext from given window 500 | 501 | **Kind**: static method of [AudioPlayer](#AudioPlayer) 502 | **Returns**: AudioContext 503 | 504 | | Param | Type | Description | 505 | | --- | --- | --- | 506 | | window | Window | Window to fetch AudioContext from | 507 | 508 | 509 | 510 | ### AudioPlayer~loadDataCallback : function 511 | loads given audio data and invokes callback when done 512 | 513 | **Kind**: inner typedef of [AudioPlayer](#AudioPlayer) 514 | 515 | | Param | Type | Default | Description | 516 | | --- | --- | --- | --- | 517 | | audioData | ArrayBuffer | | ArrayBuffer of data for audio to play | 518 | | callback | [loadDataCallback](#AudioPlayer..loadDataCallback) | | callback to invoke when audioData is finished loading | 519 | | [err] | string | null | string of error message (null if no error) | 520 | | [self] | [AudioPlayer](#AudioPlayer) | | ref to AudioPlayer instance if loading successful (undefined otherwise) | 521 | 522 | 523 | 524 | 525 | 526 | ## DataTypes 527 | 528 | * [DataTypes](#module_DataTypes) 529 | * [~MidiVisualizerState](#module_DataTypes..MidiVisualizerState) 530 | * [new MidiVisualizerState(params)](#new_module_DataTypes..MidiVisualizerState_new) 531 | * [~RendererState](#module_DataTypes..RendererState) 532 | * [new RendererState(params)](#new_module_DataTypes..RendererState_new) 533 | * [~D3RendererState](#module_DataTypes..D3RendererState) ⇐ RendererState 534 | * [new D3RendererState()](#new_module_DataTypes..D3RendererState_new) 535 | * [~ThreeJsRendererState](#module_DataTypes..ThreeJsRendererState) ⇐ RendererState 536 | * [new ThreeJsRendererState()](#new_module_DataTypes..ThreeJsRendererState_new) 537 | * [~AnimEvent](#module_DataTypes..AnimEvent) 538 | * [new AnimEvent([id])](#new_module_DataTypes..AnimEvent_new) 539 | * [~RenderEvent](#module_DataTypes..RenderEvent) 540 | * [new RenderEvent()](#new_module_DataTypes..RenderEvent_new) 541 | * [~D3RenderEvent](#module_DataTypes..D3RenderEvent) ⇐ RenderEvent 542 | * [new D3RenderEvent()](#new_module_DataTypes..D3RenderEvent_new) 543 | * [~ThreeJsRenderEvent](#module_DataTypes..ThreeJsRenderEvent) ⇐ RenderEvent 544 | * [new ThreeJsRenderEvent()](#new_module_DataTypes..ThreeJsRenderEvent_new) 545 | 546 | 547 | 548 | ### DataTypes~MidiVisualizerState 549 | **Kind**: inner class of [DataTypes](#module_DataTypes) 550 | 551 | 552 | #### new MidiVisualizerState(params) 553 | top-level data type representing state of MidiVisualizer 554 | 555 | **Returns**: MidiVisualizerState 556 | 557 | | Param | Type | Default | Description | 558 | | --- | --- | --- | --- | 559 | | params | object | | properties to set | 560 | | params.audioPlayer | [AudioPlayer](#AudioPlayer) | | AudioPlayer instance managing audio to sync with | 561 | | params.renderer | Renderer | | Renderer used to draw visualization | 562 | | [params.animEventsByTimeMs] | object | {} | AnimEvent to render, grouped by millisecond-based mark where they should be rendered | 563 | | [params.isPlaying] | boolean | false | flag indicating whether currently playing | 564 | 565 | 566 | 567 | ### DataTypes~RendererState 568 | **Kind**: inner class of [DataTypes](#module_DataTypes) 569 | 570 | 571 | #### new RendererState(params) 572 | top-level data type representing state of Renderer 573 | 574 | **Returns**: RendererState 575 | 576 | | Param | Type | Default | Description | 577 | | --- | --- | --- | --- | 578 | | params | object | | properties to set | 579 | | params.id | string | | unique identifier for renderer | 580 | | params.root | HTMLElement | | HTMLElement to use as root node for renderer canvas placement | 581 | | params.window | Window | | Window we are rendering in (note, Window must have a 'document') | 582 | | [params.width] | number | 0 | width for rendering canvas | 583 | | [params.height] | number | 0 | height for rendering canvas | 584 | | [param.renderEvents] | Array.<RenderEvents> | [] | RenderEvents to render | 585 | | [params.scales] | Array.<object> | [] | Scales for normalizing position/sizing | 586 | | [params.isPlaying] | boolean | false | flag indicating whether currently playing | 587 | 588 | 589 | 590 | ### DataTypes~D3RendererState ⇐ RendererState 591 | **Kind**: inner class of [DataTypes](#module_DataTypes) 592 | **Extends:** RendererState 593 | 594 | 595 | #### new D3RendererState() 596 | data type representing state of Renderer that uses D3 597 | 598 | **Returns**: D3RendererState 599 | 600 | | Param | Type | Description | 601 | | --- | --- | --- | 602 | | params.svg | SVGElement | SVGElement for renderering | 603 | 604 | 605 | 606 | ### DataTypes~ThreeJsRendererState ⇐ RendererState 607 | **Kind**: inner class of [DataTypes](#module_DataTypes) 608 | **Extends:** RendererState 609 | 610 | 611 | #### new ThreeJsRendererState() 612 | data type representing state of Renderer that uses D3 613 | 614 | **Returns**: ThreeJsRendererState 615 | 616 | | Param | Type | Description | 617 | | --- | --- | --- | 618 | | params.THREE | THREEJS | ThreeJs object | 619 | | params.camera | Camera | ThreeJs Camera to use | 620 | | params.scene | Scene | ThreeJs Scene to use | 621 | | params.renderer | Renderer | Renderer monad to use | 622 | 623 | 624 | 625 | ### DataTypes~AnimEvent 626 | **Kind**: inner class of [DataTypes](#module_DataTypes) 627 | 628 | 629 | #### new AnimEvent([id]) 630 | data type representing individual animation event 631 | 632 | **Returns**: AnimEvent 633 | 634 | | Param | Type | Default | Description | 635 | | --- | --- | --- | --- | 636 | | params.event | MidiEvent | | MidiEvent being renderered | 637 | | [params.track] | number | 0 | index of midi track event belongs to | 638 | | [params.startTimeMicroSec] | number | 0 | offset in microseconds from beginning of song when event starts | 639 | | [params.lengthMicroSec] | number | 0 | length of event in microseconds | 640 | | [params.microSecPerBeat] | number | 500000 | number of microseconds per beat | 641 | | [id] | string | "<track>-<event.note || startTimeInMicroSec>" | unique ID of event | 642 | 643 | 644 | 645 | ### DataTypes~RenderEvent 646 | **Kind**: inner class of [DataTypes](#module_DataTypes) 647 | 648 | 649 | #### new RenderEvent() 650 | base data type representing individual render event 651 | 652 | **Returns**: RenderEvent 653 | 654 | | Param | Type | Default | Description | 655 | | --- | --- | --- | --- | 656 | | params.id | id | | unique string identifier for event | 657 | | params.track | number | | index of midi track event belongs to | 658 | | params.subtype | string | | midi event subtype | 659 | | params.x | number | | x position for element | 660 | | params.y | number | | y position for element | 661 | | params.lengthMicroSec | number | | length of event in microseconds | 662 | | params.microSecPerBeat | number | | number of microseconds per beat | 663 | | [params.z] | number | 0 | z position for element | 664 | | [params.microSecPerBeat] | number | 500000 | number of microseconds per beat | 665 | | [params.color] | string | "'#FFFFFF'" | color of element to render | 666 | 667 | 668 | 669 | ### DataTypes~D3RenderEvent ⇐ RenderEvent 670 | **Kind**: inner class of [DataTypes](#module_DataTypes) 671 | **Extends:** RenderEvent 672 | 673 | 674 | #### new D3RenderEvent() 675 | data type representing individual render event using D3 676 | 677 | **Returns**: D3RenderEvent 678 | 679 | | Param | Type | Description | 680 | | --- | --- | --- | 681 | | [params.path] | string | SVG path string (required if no 'radius' given) | 682 | | [params.radius] | number | radius to use for rendering circle (required if no 'path' given) | 683 | | [params.scale] | d3.Scale | D3.Scale (required if 'path' is given) | 684 | 685 | 686 | 687 | ### DataTypes~ThreeJsRenderEvent ⇐ RenderEvent 688 | **Kind**: inner class of [DataTypes](#module_DataTypes) 689 | **Extends:** RenderEvent 690 | 691 | 692 | #### new ThreeJsRenderEvent() 693 | data type representing individual render event using ThreeJS 694 | 695 | **Returns**: ThreeJsRenderEvent 696 | 697 | | Param | Type | Default | Description | 698 | | --- | --- | --- | --- | 699 | | [params.scale] | number | 1 | scaling factor | 700 | | [params.zRot] | number | 0 | z-rotation | 701 | | [params.xRot] | number | 0 | x-rotation | 702 | | [params.yRot] | number | 0 | y-rotation | 703 | | [params.note] | number | | midi note value (0-127) | 704 | | [params.shape] | THREEJS~Object3D | | ThreeJs Object3D of shape representing this event | 705 | 706 | 707 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | visualizer: require('./src/midi-visualizer'), 5 | renderers: require('./src/renderers'), 6 | dataTypes: require('./src/data-types') 7 | }; 8 | -------------------------------------------------------------------------------- /jsdoc.template.hbs: -------------------------------------------------------------------------------- 1 | # midi-visualizer 2 | 3 | [![Build Status](https://travis-ci.org/edhille/midi-visualizer.svg?branch=master)](https://travis-ci.org/edhille/midi-visualizer) 4 | [![Coverage Status](https://coveralls.io/repos/github/edhille/midi-visualizer/badge.svg?branch=master)](https://coveralls.io/github/edhille/midi-visualizer?branch=master) 5 | 6 | A simple, functional-based midi visualization library 7 | 8 | ## Example 9 | 10 | ``` 11 | const initMidiVisualizer = import 'midi-visualizer'; 12 | const config = { 13 | window: window, 14 | root: document.getElementById('#my-root'), 15 | width: 500, 16 | height: 500, 17 | midi: { 18 | data: myFnToFetchMidiData() 19 | }, 20 | audio: { 21 | data: myFnToFetchAudioData() 22 | }, 23 | renderer: setupMyCustomRenderer() 24 | }; 25 | 26 | initMidiVisualizer(config).then((visualizer) => { 27 | const playingVisualizer = visualizer.play(); 28 | // all your other fun operations... 29 | }).catch((error) => console.error('Oh man, something bad happened:', error)); 30 | ``` 31 | 32 | ## API Reference 33 | 34 | {{#module name="midiVisualizer"}}{{>docs}}{{/module}} 35 | 36 | {{#namespace name="midi-visualizer"}}{{>docs}}{{/namespace}} 37 | 38 | {{#module name="RenderUtils"}}{{>docs}}{{/module}} 39 | 40 | {{#module name="ThreeJsRenderer"}}{{>docs}}{{/module}} 41 | 42 | {{#module name="D3Renderer"}}{{>docs}}{{/module}} 43 | 44 | {{#class name="AudioPlayer"}}{{>docs}}{{/class}} 45 | 46 | {{#module name="DataTypes"}}{{>docs}}{{/module}} 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "func-midi-visualizer", 3 | "version": "3.1.2", 4 | "description": "A functional-based visualizer for midi data, syncrhonized with audio file", 5 | "engines": { 6 | "node": ">=6.0.0" 7 | }, 8 | "main": "index.js", 9 | "directories": { 10 | "test": "test" 11 | }, 12 | "scripts": { 13 | "preinstall": "if test -d ./.git && test ! -h ./.git/hooks/commit-msg; then ln -s ./scripts/commit-msg ./.git/hooks/commit-msg; fi;", 14 | "clean": "rm -Rf ./coverage", 15 | "lint": "./node_modules/eslint/bin/eslint.js src", 16 | "test": "./node_modules/mocha/bin/mocha test", 17 | "ci-coverage": "./node_modules/istanbul/lib/cli.js cover ./node_modules/mocha/bin/_mocha --report lcovonly -- -R spec && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage", 18 | "local-coverage": "npm run clean && ./node_modules/istanbul/lib/cli.js cover _mocha test/**.spec.js", 19 | "doc": "./node_modules/jsdoc-to-markdown/bin/cli.js --template jsdoc.template.hbs 'src/**/*.js'> README.md", 20 | "check-deps": "./node_modules/npm-check-updates/bin/ncu -e 2", 21 | "update-deps": "./node_modules/npm-check-updates/bin/ncu -u -a" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/edhille/midi-visualizer.git" 26 | }, 27 | "keywords": [ 28 | "JavaScript", 29 | "Midi", 30 | "Visualization", 31 | "Functional" 32 | ], 33 | "author": "Ted Hille", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/edhille/midi-visualizer/issues" 37 | }, 38 | "homepage": "https://github.com/edhille/midi-visualizer", 39 | "devDependencies": { 40 | "chai": "^4.2.0", 41 | "coveralls": "^3.0.3", 42 | "del": "^4.0.0", 43 | "eslint": "^5.16.0", 44 | "istanbul": "^0.4.5", 45 | "jsdoc": "^3.5.5", 46 | "jsdoc-to-markdown": "^4.0.1", 47 | "mocha": "^6.1.3", 48 | "npm-check-updates": "^3.1.3", 49 | "rewire": "^4.0.1", 50 | "sinon": "^7.3.1", 51 | "through2": "^3.0.1" 52 | }, 53 | "dependencies": { 54 | "d3": "^5.9.2", 55 | "fadt": "^2.2.4", 56 | "func-midi-parser": "^2.1.3", 57 | "funtils": "^0.5.0", 58 | "graceful-readlink": "^1.0.1", 59 | "lodash": "^4.17.11", 60 | "three": "^0.103.0" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /scripts/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if npm run doc >/dev/null 2>&1 4 | then 5 | if git status --porcelain | grep ' M README.md' >/dev/null 2>&1 6 | then 7 | echo 'You have unstaged README updates that need to be committed' 8 | git status -s 9 | exit 1 10 | elif npm run check-deps >/dev/null 2>&1 11 | then 12 | echo 'You have outdated dependencies' 13 | exit 1 14 | fi 15 | else 16 | exit 1 17 | fi 18 | 19 | exit 0 20 | -------------------------------------------------------------------------------- /src/audio-player.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const utils = require('funtils'); 4 | const existy = utils.existy; 5 | 6 | const SEC_TO_MS = 1000; 7 | 8 | function calcPlayhead(currTimeSec, lastStartTime, lastPlayheadOffsetSec, startOffsetSec, durationSec) { 9 | const calculatedTimeSec = startOffsetSec + lastPlayheadOffsetSec + (currTimeSec - lastStartTime); 10 | return calculatedTimeSec % durationSec; 11 | } 12 | 13 | /** 14 | * @class AudioPlayer 15 | * @description manages audio playback 16 | * @param {object} params - settings for audio player 17 | * @param {Window} params.window - Window used to retrieve AudioContext 18 | * @return AudioPlayer 19 | */ 20 | function AudioPlayer(params) { 21 | params = params || {}; 22 | 23 | const ContextClass = AudioPlayer.getAudioContextFromWindow(params.window); 24 | 25 | if (ContextClass) { 26 | this.context = new ContextClass(); 27 | } else { 28 | throw new TypeError('AudioContext not supported'); 29 | } 30 | 31 | this.isLoading = false; 32 | this.isLoaded = false; 33 | this.isPlaying = false; 34 | 35 | this.buffer = null; 36 | 37 | this.lastPlayheadOffsetSec = 0; 38 | this.lastStartTimeSec = 0; 39 | this.startOffsetSec = 0; 40 | this.lengthMs = 0; 41 | } 42 | 43 | Object.defineProperties(AudioPlayer, { 44 | context: { 45 | value: null, 46 | writable: false, 47 | configurable: false, 48 | enumerable: false 49 | }, 50 | audioSource: { 51 | value: null, 52 | writable: true, 53 | configurable: false, 54 | enumerable: true 55 | }, 56 | isLoading: { 57 | value: false, 58 | writeable: false, 59 | configurable: false, 60 | enumerable: true 61 | }, 62 | isLoaded: { 63 | value: false, 64 | writeable: false, 65 | configurable: false, 66 | enumerable: true 67 | }, 68 | isPlaying: { 69 | value: false, 70 | writeable: false, 71 | configurable: false, 72 | enumerable: true 73 | }, 74 | buffer: { 75 | value: null, 76 | writeable: false, 77 | configurable: false, 78 | enumerable: false 79 | }, 80 | lastStartTimeSec: { 81 | value: 0, 82 | writeable: false, 83 | configurable: false, 84 | enumerable: false 85 | }, 86 | startOffsetSec: { 87 | value: 0, 88 | writeable: false, 89 | configurable: false, 90 | enumerable: false 91 | }, 92 | lastPlayheadOffsetSec: { 93 | value: 0, 94 | writeable: false, 95 | configurable: false, 96 | enumerable: false 97 | }, 98 | lengthMs: { 99 | value: 0, 100 | writeable: false, 101 | configurable: false, 102 | enumerable: true 103 | } 104 | }); 105 | 106 | /** 107 | * @method AudioPlayer#loadData 108 | * @description loads given audio data and invokes callback when done 109 | * @param {ArrayBuffer} audioData - ArrayBuffer of data for audio to play 110 | * @param {AudioPlayer~loadDataCallback} callback - callback to invoke when audioData is finished loading 111 | * 112 | * @callback AudioPlayer~loadDataCallback 113 | * @param {string} [err=null] - string of error message (null if no error) 114 | * @param {AudioPlayer} [self] - ref to AudioPlayer instance if loading successful (undefined otherwise) 115 | */ 116 | AudioPlayer.prototype.loadData = function loadData(audioData, callback) { /* jshint expr: true */ 117 | const self = this; 118 | 119 | if (!existy(audioData)) throw new Error('must provide an AudioData source'); 120 | 121 | if (!existy(callback) && typeof callback !== 'function') throw new Error('callback required'); 122 | 123 | if (self.isLoading) { 124 | callback('Already loading audio data'); 125 | 126 | return; 127 | } 128 | 129 | try { 130 | self.isLoading = true; 131 | self.context.decodeAudioData(audioData, setupAudioSource); 132 | } catch (e) { 133 | callback('error decoding audio: ' + e.message); 134 | } 135 | 136 | function setupAudioSource(buffer) { 137 | self.buffer = buffer; 138 | self.isLoading = false; 139 | self.isLoaded = true; 140 | self.lengthMs = buffer.duration * SEC_TO_MS; 141 | 142 | callback(null, self); 143 | } 144 | }; 145 | 146 | /** 147 | * @method AudioPlayer#getPlayheadTime 148 | * @description gets the playhead time in milliseconds 149 | * @return playheadTimeMs 150 | */ 151 | AudioPlayer.prototype.getPlayheadTime = function getPlayheadTime() { 152 | if (!this.isLoaded || this.isLoading) return 0; 153 | if (!this.isPlaying) return this.lastPlayheadOffsetSec * SEC_TO_MS; 154 | 155 | return calcPlayhead(this.context.currentTime, this.lastStartTimeSec, this.lastPlayheadOffsetSec, this.startOffsetSec, this.buffer.duration) * SEC_TO_MS; 156 | }; 157 | 158 | /** 159 | * @method AudioPlayer#play 160 | * @description initiates playing of audio 161 | * @param {number} [startTimeOffset=0] - offset in seconds to wait before playing 162 | * @param {number} [playheadSec=0] - where to start playback within audio in seconds 163 | */ 164 | AudioPlayer.prototype.play = function play(startTimeOffsetSec, playheadSec) { 165 | if (!this.isLoaded) return false; // nothing to play... 166 | if (this.isPlaying) return true; // already playing 167 | 168 | // remove our handler to pause playback at the end of audio if we are restarting 169 | // play because the old audio source will end and call this... 170 | if (this.audioSource) this.audioSource.onended = null; 171 | 172 | this.audioSource = this.context.createBufferSource(); 173 | this.audioSource.buffer = this.buffer; 174 | this.audioSource.connect(this.context.destination); 175 | 176 | this.startOffsetSec = startTimeOffsetSec || 0; 177 | if (typeof playheadSec !== 'undefined') this.lastPlayheadOffsetSec = playheadSec; 178 | this.lastStartTimeSec = this.context.currentTime; 179 | 180 | const newPlayheadSec = calcPlayhead(this.context.currentTime, this.lastStartTimeSec, this.lastPlayheadOffsetSec, this.startOffsetSec, this.buffer.duration); 181 | 182 | this.audioSource.start(this.startOffsetSec, newPlayheadSec); 183 | 184 | this.isPlaying = true; 185 | 186 | this.audioSource.onended = function () { 187 | this.pause(); 188 | }.bind(this); 189 | 190 | return this.isPlaying; 191 | }; 192 | 193 | /** 194 | * @method AudioPlayer#pause 195 | * @description pauses playing of audio 196 | * @param {number} stopAfter - number of seconds to wait before stopping 197 | */ 198 | AudioPlayer.prototype.pause = function pause( /* AudioBufferSourceNode.stop params */ ) { 199 | if (!this.isLoaded) return false; // nothing to play... 200 | if (!this.isPlaying) return true; // already paused 201 | 202 | this.isPlaying = false; 203 | this.lastPlayheadOffsetSec = calcPlayhead(this.context.currentTime, this.lastStartTimeSec, this.lastPlayheadOffsetSec, this.startOffsetSec, this.buffer.duration); 204 | 205 | return this.audioSource.stop.apply(this.audioSource, arguments); 206 | }; 207 | 208 | /** 209 | * @method 210 | * @static 211 | * @description cross-browser fetch of AudioContext from given window 212 | * @param {Window} window - Window to fetch AudioContext from 213 | * @return AudioContext 214 | */ 215 | AudioPlayer.getAudioContextFromWindow = function getAudioContextFromWindow(window) { 216 | return window.AudioContext || 217 | window.webkitAudioContext || 218 | window.mozAudioContext || 219 | window.oAudioContext || 220 | window.msAudioContext; 221 | }; 222 | 223 | module.exports = AudioPlayer; 224 | 225 | -------------------------------------------------------------------------------- /src/data-types.js: -------------------------------------------------------------------------------- 1 | /** @module DataTypes */ 2 | 'use strict'; 3 | 4 | const createDataType = require('fadt'); 5 | 6 | const DEFAULT_STROKE = 1; 7 | const DEFAULT_STROKE_LINE_CAP = 'round'; 8 | 9 | /** 10 | * @class MidiVisualizerState 11 | * @description top-level data type representing state of MidiVisualizer 12 | * @param {object} params - properties to set 13 | * @param {AudioPlayer} params.audioPlayer - AudioPlayer instance managing audio to sync with 14 | * @param {Renderer} params.renderer - Renderer used to draw visualization 15 | * @param {object} [params.animEventsByTimeMs={}] - AnimEvent to render, grouped by millisecond-based mark where they should be rendered 16 | * @param {boolean} [params.isPlaying=false] - flag indicating whether currently playing 17 | * @returns MidiVisualizerState 18 | */ 19 | const MidiVisualizerState = createDataType(function (params) { 20 | if (!params.audioPlayer) throw new TypeError('audioPlayer is required'); 21 | if (!params.renderer) throw new TypeError('renderer is required'); 22 | 23 | this.audioPlayer = params.audioPlayer; 24 | this.renderer = params.renderer; 25 | 26 | this.animEventsByTimeMs = params.animEventsByTimeMs || {}; 27 | this.isPlaying = params.isPlaying || false; 28 | }); 29 | 30 | /** 31 | * @class RendererState 32 | * @description top-level data type representing state of Renderer 33 | * @param {object} params - properties to set 34 | * @param {string} params.id - unique identifier for renderer 35 | * @param {HTMLElement} params.root - HTMLElement to use as root node for renderer canvas placement 36 | * @param {Window} params.window - Window we are rendering in (note, Window must have a 'document') 37 | * @param {number} [params.width=0] - width for rendering canvas 38 | * @param {number} [params.height=0] - height for rendering canvas 39 | * @param {RenderEvents[]} [param.renderEvents=[]] - RenderEvents to render 40 | * @param {object[]} [params.scales=[]] - Scales for normalizing position/sizing 41 | * @param {boolean} [params.isPlaying=false] - flag indicating whether currently playing 42 | * @returns RendererState 43 | */ 44 | const RendererState = createDataType(function (params) { 45 | if (!params.id) throw new TypeError('id required'); 46 | if (!params.root) throw new TypeError('root required'); 47 | if (!params.window) throw new TypeError('window required'); 48 | if (!params.window.document) throw new TypeError('window must have document property'); 49 | if (!params.animEventsByTimeMs) throw new TypeError('animEventsByTimeMs required'); 50 | 51 | this.id = params.id; 52 | this.root = params.root; 53 | this.window = params.window; 54 | this.document = params.window.document; 55 | 56 | this.width = params.width || 0; 57 | this.height = params.height || 0; 58 | this.renderEvents = params.renderEvents || []; 59 | this.scales = params.scales || []; 60 | this.isPlaying = params.isPlaying || false; 61 | 62 | this.animEventsByTimeMs = params.animEventsByTimeMs || {}; 63 | }); 64 | 65 | /** 66 | * @class D3RendererState 67 | * @augments RendererState 68 | * @description data type representing state of Renderer that uses D3 69 | * @param {SVGElement} params.svg - SVGElement for renderering 70 | * @returns D3RendererState 71 | */ 72 | const D3RendererState = createDataType(function (params) { 73 | if(!params.svg) throw new TypeError('svg is required'); 74 | if(!params.d3) throw new TypeError('d3 is required'); 75 | 76 | this.svg = params.svg; 77 | this.d3 = params.d3; 78 | }, RendererState); 79 | 80 | /** 81 | * @class ThreeJsRendererState 82 | * @augments RendererState 83 | * @description data type representing state of Renderer that uses D3 84 | * @param {THREEJS} params.THREE - ThreeJs object 85 | * @param {Camera} params.camera - ThreeJs Camera to use 86 | * @param {Scene} params.scene - ThreeJs Scene to use 87 | * @param {Renderer} params.renderer - Renderer monad to use 88 | * @returns ThreeJsRendererState 89 | */ 90 | const ThreeJsRendererState = createDataType(function (params) { 91 | if (!params.THREE) throw new TypeError('THREE is required'); 92 | if (!params.camera) throw new TypeError('camera is required'); 93 | if (!params.scene) throw new TypeError('scene is required'); 94 | if (!params.renderer) throw new TypeError('renderer is required'); 95 | 96 | this.THREE = params.THREE; 97 | this.camera = params.camera; 98 | this.scene = params.scene; 99 | this.renderer = params.renderer; 100 | }, RendererState); 101 | 102 | /** 103 | * @class AnimEvent 104 | * @description data type representing individual animation event 105 | * @param {MidiEvent} params.event - MidiEvent being renderered 106 | * @param {number} [params.track=0] - index of midi track event belongs to 107 | * @param {number} [params.startTimeMicroSec=0] - offset in microseconds from beginning of song when event starts 108 | * @param {number} [params.lengthMicroSec=0] - length of event in microseconds 109 | * @param {number} [params.microSecPerBeat=500000] - number of microseconds per beat 110 | * @param {string} [id=-] - unique ID of event 111 | * @returns AnimEvent 112 | */ 113 | const AnimEvent = createDataType(function (params) { 114 | if (!params.event) throw new TypeError('no MidiEvent passed in'); 115 | 116 | this.event = params.event; 117 | this.track = params.track || 0; 118 | this.startTimeMicroSec = params.startTimeMicroSec || 0; 119 | this.lengthMicroSec = params.lengthMicroSec || 0; 120 | this.microSecPerBeat = params.microSecPerBeat || 500000; 121 | this.id = params.id || this.track + '-' + (this.event.note || this.startTimeInMicroSec); 122 | }); 123 | 124 | /** 125 | * @class RenderEvent 126 | * @description base data type representing individual render event 127 | * @param {id} params.id - unique string identifier for event 128 | * @param {number} params.track - index of midi track event belongs to 129 | * @param {string} params.subtype - midi event subtype 130 | * @param {number} params.x - x position for element 131 | * @param {number} params.y - y position for element 132 | * @param {number} params.lengthMicroSec - length of event in microseconds 133 | * @param {number} params.microSecPerBeat - number of microseconds per beat 134 | * @param {number} [params.z=0] - z position for element 135 | * @param {number} [params.microSecPerBeat=500000] - number of microseconds per beat 136 | * @param {string} [params.color='#FFFFFF'] - color of element to render 137 | * @param {string} [params.opacity=1.0] - opacity of element 138 | * @returns RenderEvent 139 | */ 140 | const RenderEvent = createDataType(function (params) { 141 | if (typeof params.id === 'undefined') throw new TypeError('no id passed in'); 142 | if (typeof params.track === 'undefined') throw new TypeError('no track passed in'); 143 | if (typeof params.subtype === 'undefined') throw new TypeError('no subtype passed in'); 144 | if (typeof params.x === 'undefined') throw new TypeError('no x passed in'); 145 | if (typeof params.y === 'undefined') throw new TypeError('no y passed in'); 146 | if (typeof params.lengthMicroSec === 'undefined') throw new TypeError('no lengthMicroSec passed in'); 147 | if (typeof params.startTimeMicroSec === 'undefined') throw new TypeError('no startTimeMicroSec passed in'); 148 | if (typeof params.event === 'undefined') throw new TypeError('no event passed in'); 149 | 150 | this.id = params.id; 151 | this.event = params.event; 152 | this.track = params.track; 153 | this.subtype = params.subtype; // should be "on" or "off" 154 | 155 | // All render events have positioning information 156 | this.x = params.x; 157 | this.y = params.y; 158 | this.z = params.z || 0; // Only used in three-dimensional rendering 159 | 160 | this.lengthMicroSec = params.lengthMicroSec; // how long this event should live 161 | this.startTimeMicroSec = params.startTimeMicroSec; // when this event is occurring 162 | this.microSecPerBeat = params.microSecPerBeat || 500000; 163 | 164 | this.color = params.color || '#FFFFFF'; 165 | this.opacity = params.opacity || 1.0; 166 | }); 167 | 168 | /** 169 | * @class D3RenderEvent 170 | * @augments RenderEvent 171 | * @description data type representing individual render event using D3 172 | * @param {string} [params.path] - SVG path string (required if no 'radius' given) 173 | * @param {number} [params.radius] - radius to use for rendering circle (required if no 'path' given) 174 | * @param {d3.Scale} [params.scale] - D3.Scale (required if 'path' is given) 175 | * @param {d3.Transition} [params.transition] - D3.Transition to use for element transition 176 | * @returns D3RenderEvent 177 | */ 178 | const D3RenderEvent = createDataType(function (params) { 179 | if ( 180 | typeof params.path === 'undefined' && 181 | typeof params.line === 'undefined' && 182 | typeof params.circle === 'undefined' 183 | ) throw new TypeError('must provide either a "path", "line", or "circle"'); 184 | 185 | if ( 186 | typeof params.scale === 'undefined' && 187 | typeof params.path !== 'undefined' 188 | ) throw new TypeError('scale required if path passed in'); 189 | 190 | this.path = params.path; 191 | this.line = params.line; 192 | this.circle = params.circle; 193 | 194 | this.stroke = params.stroke || DEFAULT_STROKE; 195 | this.strokeLineCap = params.strokeLineCap || DEFAULT_STROKE_LINE_CAP; 196 | 197 | this.scale = params.scale; 198 | this.transition = params.transition; 199 | }, RenderEvent); 200 | 201 | /** 202 | * @class ThreeJsRenderEvent 203 | * @augments RenderEvent 204 | * @description data type representing individual render event using ThreeJS 205 | * @param {number} [params.scale=1] - scaling factor 206 | * @param {number} [params.zRot=0] - z-rotation 207 | * @param {number} [params.xRot=0] - x-rotation 208 | * @param {number} [params.yRot=0] - y-rotation 209 | * @param {number} [params.note] - midi note value (0-127) 210 | * @param {THREEJS~Object3D} [params.shape] - ThreeJs Object3D of shape representing this event 211 | * @returns ThreeJsRenderEvent 212 | */ 213 | const ThreeJsRenderEvent = createDataType(function (params) { 214 | if (typeof params.z === 'undefined') throw new TypeError('no z passed in'); 215 | 216 | this.scale = params.scale || 1; 217 | 218 | this.z = params.z; 219 | 220 | this.zRot = params.zRot || 0; 221 | this.xRot = params.xRot || 0; 222 | this.yRot = params.yRot || 0; 223 | 224 | // TODO: need to test this 225 | this.shape = params.shape; 226 | 227 | this.note = params.note; 228 | }, RenderEvent); 229 | 230 | module.exports = { 231 | MidiVisualizerState: MidiVisualizerState, 232 | RendererState: RendererState, 233 | D3RendererState: D3RendererState, 234 | ThreeJsRendererState: ThreeJsRendererState, 235 | AnimEvent: AnimEvent, 236 | RenderEvent: RenderEvent, 237 | D3RenderEvent: D3RenderEvent, 238 | ThreeJsRenderEvent: ThreeJsRenderEvent 239 | }; 240 | -------------------------------------------------------------------------------- /src/midi-transformer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'); 4 | const types = require('./data-types'); 5 | const AnimEvent = types.AnimEvent; 6 | const midiParser = require('func-midi-parser'); 7 | const MidiNoteEvent = midiParser.types.MidiNoteEvent; 8 | const MidiNoteOnEvent = midiParser.types.MidiNoteOnEvent; 9 | const MidiNoteOffEvent = midiParser.types.MidiNoteOffEvent; 10 | const MidiMetaTempoEvent = midiParser.types.MidiMetaTempoEvent; 11 | 12 | function trackEventFilter(event) { 13 | return event instanceof MidiNoteEvent || event instanceof MidiMetaTempoEvent; 14 | } 15 | 16 | /** 17 | * @description this function takes a Midi object and transforms all the note events 18 | * into AnimEvents, grouped by the time (rounded to the nearest millisecond) 19 | * they occur within the song. 20 | * @param {Midi} midi - midi data to transform 21 | */ 22 | function mapToAnimEvents(midi) { 23 | let tempo = midiParser.constants.DEFAULT_MIDI_TEMPO; // 120bpm 24 | let tickInMicroSec = tempo / midi.header.timeDivision; 25 | let totalTimeMicroSec = 0; 26 | 27 | const animEvents = midi.tracks.reduce((animEvents, track, trackIndex) => { 28 | const activeNotes = {}; 29 | let elapsedTimeInMicroSec = 0; 30 | 31 | const trackEvents = track.events.reduce((eventsSoFar, event) => { 32 | const newEvents = []; 33 | let startNote = {}; 34 | 35 | if (event instanceof MidiMetaTempoEvent) { 36 | // NOTE: this "should" be the first event in a track if not, 37 | // we would really need to go back and revise the time for all events... 38 | tempo = event.microsecPerQn; 39 | tickInMicroSec = Math.floor(tempo / midi.header.timeDivision); 40 | 41 | return eventsSoFar; 42 | } 43 | 44 | elapsedTimeInMicroSec += event.delta * tickInMicroSec; 45 | 46 | const eventTimeInMicroSec = elapsedTimeInMicroSec; 47 | 48 | if (!trackEventFilter(event)) return eventsSoFar; 49 | 50 | if (event instanceof MidiNoteOnEvent) { 51 | // start tracking a note "start" 52 | activeNotes[event.note] = activeNotes[event.note] || []; 53 | activeNotes[event.note].push({ 54 | event: event, 55 | startTimeMicroSec: eventTimeInMicroSec, 56 | }); 57 | 58 | return eventsSoFar; 59 | } else if (event instanceof MidiNoteOffEvent) { 60 | startNote = activeNotes[event.note] ? activeNotes[event.note][0] : null; 61 | 62 | if (startNote) { 63 | const lengthMicroSec = elapsedTimeInMicroSec - startNote.startTimeMicroSec; 64 | const newEvent = new AnimEvent({ 65 | event: Object.assign({}, startNote.event), 66 | lengthMicroSec: lengthMicroSec, 67 | track: trackIndex, 68 | startTimeMicroSec: startNote.startTimeMicroSec, 69 | microSecPerBeat: tempo 70 | }); 71 | 72 | activeNotes[event.note].pop(); 73 | 74 | /* istanbul ignore else */ 75 | if (activeNotes[event.note].length === 0) delete activeNotes[event.note]; 76 | 77 | newEvents.push(newEvent); 78 | } else { 79 | /*eslint-disable no-console */ 80 | console.error('no active note "' + event.note + '", track "' + trackIndex + '"'); 81 | 82 | return eventsSoFar; 83 | } 84 | } else { 85 | /*eslint-disable no-console */ 86 | console.error('UNEXPECTED EVENT: ', event); 87 | } 88 | 89 | /* istanbul ignore else */ 90 | if (!activeNotes[event.note] || activeNotes[event.note].length <= 1) { 91 | newEvents.push(new AnimEvent({ 92 | event: Object.assign({}, event), 93 | lengthMicroSec: 0, 94 | track: trackIndex, 95 | startTimeMicroSec: elapsedTimeInMicroSec, 96 | microSecPerBeat: tempo 97 | })); 98 | } 99 | 100 | return newEvents.length > 0 ? eventsSoFar.concat(newEvents) : eventsSoFar; 101 | }, []); 102 | 103 | // assume the longest track is the length of the song 104 | if (elapsedTimeInMicroSec > totalTimeMicroSec) totalTimeMicroSec = elapsedTimeInMicroSec; 105 | 106 | return trackEvents.length > 0 ? animEvents.concat(trackEvents) : animEvents; 107 | }, []); 108 | 109 | // add empty events for every 1/32 note to allow for non-note events in renderering 110 | const totalTimeMs = Math.floor(totalTimeMicroSec / 1000); 111 | const thirtySecondNoteInMs = Math.floor(tempo / 8000); 112 | 113 | const tmp = animEvents.concat(_.range(0, totalTimeMs + 1, thirtySecondNoteInMs).map(timeMs => { 114 | return new AnimEvent({ 115 | id: 'timer-' + timeMs, 116 | event: { subtype: 'timer' }, 117 | track: 0, 118 | lengthMicroSec: 0, 119 | startTimeMicroSec: timeMs * 1000, 120 | microSecPerBeat: tempo 121 | }); 122 | })); 123 | 124 | return tmp.sort((a, b) => a.startTimeMicroSec > b.startTimeMicroSec); 125 | } 126 | 127 | function transformMidi(midi) { 128 | // return groupByTime(mapToAnimEvents(midi)); 129 | let tempo = 500000; // default of 120bpm 130 | let tickInMicroSec = tempo / midi.header.timeDivision; 131 | let totalTimeMicroSec = 0; 132 | 133 | const eventsByTime = midi.tracks.reduce(function _reduceTrack(eventsByTime, track, trackIndex) { 134 | let elapsedTimeInMicroSec = 0; 135 | const activeNotes = {}; 136 | const trackEventsByTime = track.events.reduce(function _reduceEvent(eventsByTime, event) { 137 | let eventTimeInMicroSec = 0; 138 | let eventTimeInMs = 0; 139 | let startTimeMicroSec = 0; 140 | let startTimeMs = 0; 141 | let startNote = {}; 142 | let newEvent = {}; 143 | 144 | // THIS TIME INFORMATION IS USED FOR GROUPING, SO WE NEED TO EITHER CALCULATE THIS 145 | // WHEN MAPPING OVER MIDI TO ANIM EVENTS OR PASS THE MIDI TO THE GROUPING FUNCTION... 146 | if (event instanceof MidiMetaTempoEvent) { 147 | // NOTE: this "should" be the first event in a track if not, 148 | // we would really need to go back and revise the time for all events... 149 | tempo = event.microsecPerQn; 150 | tickInMicroSec = Math.floor(tempo / midi.header.timeDivision); 151 | 152 | return eventsByTime; 153 | } 154 | 155 | elapsedTimeInMicroSec += event.delta * tickInMicroSec; 156 | eventTimeInMicroSec = elapsedTimeInMicroSec; 157 | 158 | /* istanbul ignore else */ 159 | if (!trackEventFilter(event)) { 160 | return eventsByTime; 161 | } 162 | 163 | if (event instanceof MidiNoteOnEvent) { 164 | // start tracking a note "start" 165 | activeNotes[event.note] = activeNotes[event.note] || []; 166 | activeNotes[event.note].push({ 167 | event: event, 168 | startTimeMicroSec: eventTimeInMicroSec, 169 | index: 0 // assume we are the first note for this time-slice 170 | }); 171 | } else /* istanbul ignore else */ if (event instanceof MidiNoteOffEvent) { 172 | startNote = activeNotes[event.note] ? activeNotes[event.note][0] : null; 173 | 174 | if (startNote) { 175 | startNote.lengthMicroSec = elapsedTimeInMicroSec - startNote.startTimeMicroSec; 176 | startTimeMicroSec = startNote.startTimeMicroSec; 177 | startTimeMs = Math.floor(startTimeMicroSec / 1000); 178 | newEvent = new AnimEvent({ 179 | event: Object.assign({}, startNote.event), 180 | lengthMicroSec: startNote.lengthMicroSec, 181 | track: trackIndex, 182 | startTimeMicroSec: startNote.startTimeMicroSec, 183 | microSecPerBeat: tempo 184 | }); 185 | 186 | eventsByTime[startTimeMs][startNote.index] = newEvent; 187 | activeNotes[event.note].pop(); 188 | 189 | /* istanbul ignore else */ 190 | if (activeNotes[event.note].length === 0) delete activeNotes[event.note]; 191 | } else { 192 | /*eslint-disable no-console */ 193 | console.error('no active note "' + event.note + '", track "' + trackIndex + '"'); 194 | 195 | return eventsByTime; 196 | } 197 | } else { 198 | console.error('UNEXPECTED EVENT TYPE: ' + typeof event); 199 | } 200 | 201 | /* istanbul ignore else */ 202 | if (!activeNotes[event.note] || activeNotes[event.note].length <= 1) { 203 | eventTimeInMs = Math.floor(eventTimeInMicroSec / 1000); 204 | eventsByTime[eventTimeInMs] = eventsByTime[eventTimeInMs] || []; 205 | eventsByTime[eventTimeInMs].push(new AnimEvent({ 206 | event: Object.assign({}, event), 207 | lengthMicroSec: 0, 208 | track: trackIndex, 209 | startTimeMicroSec: elapsedTimeInMicroSec, 210 | microSecPerBeat: tempo 211 | })); 212 | 213 | /* istanbul ignore else */ 214 | if (activeNotes[event.note]) { 215 | // set the index (length) of the last active note such that it will be added after the note we just added 216 | activeNotes[event.note][activeNotes[event.note].length - 1].index = eventsByTime[eventTimeInMs].length - 1; 217 | } 218 | } 219 | 220 | return eventsByTime; 221 | }, eventsByTime); 222 | 223 | // assume the longest track is the length of the song 224 | if (elapsedTimeInMicroSec > totalTimeMicroSec) totalTimeMicroSec = elapsedTimeInMicroSec; 225 | 226 | return trackEventsByTime; 227 | }, {}); 228 | 229 | // add empty events for every 1/32 note to allow for non-note events in renderering 230 | const totalTimeMs = Math.floor(totalTimeMicroSec / 1000); 231 | const thirtySecondNoteInMs = Math.floor(tempo / 8000); 232 | 233 | const tmp = _.range(0, totalTimeMs + 1, thirtySecondNoteInMs).reduce(function _registerEmptyRenderEvent(eventsByTime, timeMs) { 234 | const events = eventsByTime[timeMs] || []; 235 | eventsByTime[timeMs] = events.concat([new AnimEvent({ 236 | event: { subtype: 'timer' }, 237 | track: 0, 238 | lengthMicroSec: 0, 239 | startTimeMicroSec: timeMs * 1000, 240 | microSecPerBeat: tempo 241 | })]); 242 | 243 | return eventsByTime; 244 | }, eventsByTime); 245 | 246 | return tmp; 247 | } 248 | 249 | function groupByTime(events) { 250 | return _.groupBy(events, (e) => Math.floor(e.startTimeMicroSec / 1000)); 251 | } 252 | 253 | module.exports = { 254 | transformMidi: transformMidi, 255 | groupByTime: groupByTime, 256 | }; 257 | -------------------------------------------------------------------------------- /src/midi-visualizer.js: -------------------------------------------------------------------------------- 1 | /* global Promise: true */ 2 | /** @namespace midi-visualizer */ 3 | 4 | 'use strict'; 5 | 6 | // TODO: how to mock/stub/DI AudioPlayer and midiParser in const-friendly way... 7 | let AudioPlayer = require('./audio-player'); 8 | let midiParser = require('func-midi-parser'); 9 | const utils = require('funtils'); 10 | const monad = utils.monad; 11 | const MidiVisualizerState = require('./data-types').MidiVisualizerState; 12 | 13 | /** 14 | * @module midiVisualizer 15 | * @description Monad managing visualization animation of midi data 16 | */ 17 | 18 | /** 19 | * @name midiVisualizer.play 20 | * @function 21 | * @description put MidiVisualizer into "play" state 22 | * @param {number} playheadSec - offset in seconds to start playback 23 | * @return MidiVisualizer 24 | */ 25 | // VisualizerState -> VisualizerState 26 | function playVisualizer(state, playheadSec) { 27 | playheadSec = playheadSec || 0; 28 | 29 | state.audioPlayer.play(0, playheadSec); 30 | 31 | return state.next({ 32 | isPlaying: true, 33 | renderer: state.renderer.play(state.audioPlayer) 34 | }); 35 | } 36 | 37 | /** 38 | * @name restart 39 | * @function 40 | * @description put MidiVisualizer into "play" state 41 | * @param {number} playheadSec - offset in seconds to start playback 42 | * @return MidiVisualizer 43 | */ 44 | function restartVisualizer(state, playheadSec) { 45 | playheadSec = playheadSec || 0; 46 | 47 | state.audioPlayer.play(0, playheadSec); 48 | 49 | return state.next({ 50 | isPlaying: true, 51 | renderer: state.renderer.restart(state.audioPlayer) 52 | }); 53 | } 54 | 55 | /** 56 | * @name pause 57 | * @function 58 | * @description put MidiVisualizer into "pause" state 59 | * @return MidiVisualizer 60 | */ 61 | function pauseVisualizer(state) { 62 | state.audioPlayer.pause(); 63 | 64 | return state.next({ 65 | isPlaying: false, 66 | renderer: state.renderer.pause() 67 | }); 68 | } 69 | 70 | /** 71 | * @name stop 72 | * @function 73 | * @description put MidiVisualizer into "stop" state 74 | * @return MidiVisualizer 75 | */ 76 | function stopVisualizer(state) { 77 | state.audioPlayer.pause(); 78 | 79 | return state.next({ 80 | isPlaying: false, 81 | renderer: state.renderer.stop() 82 | }); 83 | } 84 | 85 | /** 86 | * @name resize 87 | * @function 88 | * @description handle resize of page MidiVisualizer is rendering into 89 | * @return MidiVisualizer 90 | */ 91 | function resizeVisualizer(state, dimensions) { 92 | return state.next({ 93 | renderer: state.renderer.resize(dimensions) 94 | }); 95 | } 96 | 97 | const midiVisualizer = monad(); 98 | midiVisualizer.lift('play', playVisualizer); 99 | midiVisualizer.lift('restart', restartVisualizer); 100 | midiVisualizer.lift('pause', pauseVisualizer); 101 | midiVisualizer.lift('stop', stopVisualizer); 102 | midiVisualizer.lift('resize', resizeVisualizer); 103 | 104 | /** 105 | * @function midi-visualizer~initMidiVisualizer 106 | * @description initializes MidiVisualizer monad 107 | * @param {object} config - configuration data to set up MidiVisualizer 108 | * @param {UInt8Array} config.midi.data - array of unsigned 8-bit integers representing Midi data 109 | * @param {UInt8Array} config.audio.data - array of unsigned 8-bit integers representing audio data 110 | * @param {Window} config.window - Window of page holding the player 111 | * @param {HTMLElement} config.root - HTMLElement that will be the root node of the visualizer 112 | * @param {Renderer} config.render - Renderer strategy to use 113 | * @param {number} config.width - the width of our canvans 114 | * @param {number} config.height - the height of our canvans 115 | * @return {Promise(MidiVisualizer, Error)} promise that fulfills with MidiVisualizer instance 116 | */ 117 | // Config -> Promise(Visualizer, Error) 118 | module.exports = function initMidiVisualizer(config) { 119 | return new Promise(function _initPromise(resolve, reject) { 120 | try { 121 | const midiData = config.midi.data; 122 | const midi = midiParser.parse(new Uint8Array(midiData)); 123 | const audioData = config.audio.data; 124 | const audioPlayer = new AudioPlayer({ window: config.window }); 125 | 126 | audioPlayer.loadData(audioData, function _setStage(err, audioPlayer) { 127 | if (err) return reject(err); 128 | 129 | try { 130 | const state = new MidiVisualizerState({ 131 | root: config.root, 132 | width: config.width, 133 | height: config.height, 134 | audioPlayer: audioPlayer, 135 | renderer: config.renderer(midi, config) 136 | }); 137 | 138 | return resolve(midiVisualizer(state)); 139 | } catch (e) { 140 | return reject(e.stack); 141 | } 142 | }); 143 | } catch(e) { 144 | return reject(e.stack); 145 | } 146 | }); 147 | }; 148 | -------------------------------------------------------------------------------- /src/renderers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | d3: require('./renderers/d3'), 5 | three: require('./renderers/three'), 6 | utils: require('./renderers/utils') 7 | }; 8 | 9 | -------------------------------------------------------------------------------- /src/renderers/d3.js: -------------------------------------------------------------------------------- 1 | /** @module D3Renderer */ 2 | 'use strict'; 3 | 4 | const d3 = require('d3'); 5 | const funtils = require('funtils'); 6 | const monad = funtils.monad; 7 | const renderUtils = require('./utils'); 8 | const maxNote = renderUtils.maxNote; 9 | const minNote = renderUtils.minNote; 10 | const isNoteOnEvent = renderUtils.isNoteOnEvent; 11 | const { transformMidi } = require('../midi-transformer'); 12 | const D3RendererState = require('../data-types').D3RendererState; 13 | 14 | const DOM_ID = 'd3-stage'; 15 | 16 | function getId(d) { return d.id; } 17 | 18 | /** 19 | * @function 20 | * @name prepDOM 21 | * @description handles initialization of DOM for renderer 22 | * @param {Midi} midi - Midi instance of song information 23 | * @param {object} config - configuration information 24 | * @param {Window} config.window - Window where rendering will take place 25 | * @param {HTMLElement} config.root - DOM Element that will hold render canvas 26 | * @param {number} dimension.width - width of the rendering area 27 | * @param {number} dimension.height - height of the renderering area 28 | * @return {D3RendererState} 29 | */ 30 | // Midi -> Config -> D3RendererState 31 | function prepDOM(midi, config) { 32 | const w = config.window; 33 | const d = w.document; 34 | const e = d.documentElement; 35 | const x = config.width || w.innerWidth || e.clientWidth; 36 | const y = config.height || w.innerHeight|| e.clientHeight; 37 | 38 | if (!x) throw new TypeError('unable to calculate width'); 39 | if (!y) throw new TypeError('unable to calculate height'); 40 | 41 | let svg = d3.select('.' + DOM_ID); 42 | 43 | /* istanbul ignore else */ 44 | if (svg.empty()) { 45 | svg = d3.select(config.root).append('svg'); 46 | svg.attr('style', 'width: 100%; height: 100%;'); 47 | svg.attr('id', DOM_ID); 48 | svg.classed(DOM_ID, true); 49 | 50 | const g = svg.append('g'); 51 | 52 | g.classed('stage', true); 53 | 54 | const defs = svg.append('defs'); 55 | 56 | defs.attr('id', 'defs'); 57 | } 58 | 59 | const songScales = midi.tracks.reduce(function (scales, track, index) { 60 | if (track.events.length === 0) return scales; 61 | 62 | const trackScale = scales[index] = { 63 | x: d3.scaleLinear(), 64 | y: d3.scaleLinear(), 65 | note: d3.scaleLinear() 66 | }; 67 | 68 | const onNotes = track.events.filter(isNoteOnEvent); 69 | const highestNote = onNotes.reduce(maxNote, 0); 70 | const lowestNote = onNotes.reduce(minNote, highestNote); 71 | 72 | trackScale.y.range([25, y]); 73 | trackScale.y.domain([lowestNote, highestNote]); 74 | 75 | trackScale.x.range([25, x]); 76 | trackScale.x.domain([lowestNote, highestNote]); 77 | 78 | trackScale.note.range([50, 100]); 79 | trackScale.note.domain(trackScale.x.domain()); 80 | 81 | trackScale.hue = d3.scaleLinear().range([0,360]).domain([0,8]); 82 | trackScale.velocity = d3.scaleLinear().range([30,60]).domain([0, 256]); 83 | 84 | return scales; 85 | }, []); 86 | 87 | return new D3RendererState({ 88 | id: DOM_ID, 89 | window: w, 90 | root: config.root, 91 | width: x, 92 | height: y, 93 | scales: config.scalesTuner ? config.scalesTuner(songScales, x, y) : songScales, 94 | svg: svg, 95 | d3: d3, 96 | animEventsByTimeMs: [], 97 | }); 98 | } 99 | 100 | /** 101 | * @description deals with resizing of the browser window 102 | * @param {D3RendererState} state - current renderer state 103 | * @param {object} dimension - dimensions of render area 104 | * @param {number} dimension.width 105 | * @param {number} dimension.height 106 | * @return {D3RendererState} 107 | */ 108 | // D3RendererState -> {width,height} -> D3RendererState 109 | function resize(state, dimension) { 110 | const width = dimension.width; 111 | const height = dimension.height; 112 | 113 | return state.next({ 114 | width: width, 115 | height: height, 116 | scales: state.scales.map(function (scale) { 117 | scale.y.range([25, height]); 118 | scale.x.range([25, width]); 119 | 120 | return scale; 121 | }) 122 | }); 123 | } 124 | 125 | // function shouldSetShapes(events) { events && events.length > 0 && events[0] instanceof D3RenderEvent; } 126 | 127 | /** 128 | * @function 129 | * @name generate 130 | * @description generator to create D3Renderer 131 | * @param {object} renderConfig - configuration data for renderer 132 | * @param {frameRenderCb} renderConfig.frameRenderer - callback for rendering individual frames 133 | * @return {D3Renderer} 134 | */ 135 | /** 136 | * @callback D3Renderer~frameRenderCb 137 | * @param {D3RendererState} state - current D3RendererState 138 | * @param {object} shapes[] - D3 shapes to be renderered 139 | * @return undefined 140 | */ 141 | /** 142 | * @name generateReturnFn 143 | * @function 144 | * @description function returned to user for creating instance of D3Renderer 145 | * @param {Midi} midi - Midi data to be renderered 146 | * @param {object} config - configuration information 147 | * @param {Window} config.window - Window where rendering will take place 148 | * @param {HTMLElement} config.root - DOM Element that will hold render canvas 149 | * @param {number} dimension.width - width of the rendering area 150 | * @param {number} dimension.height - height of the renderering area 151 | * @return D3Renderer 152 | */ 153 | // Config -> (Midi -> Config -> Renderer) 154 | function generate(renderConfig) { 155 | const renderer = monad(); 156 | 157 | renderer.DOM_ID = DOM_ID; 158 | 159 | /* istanbul ignore next */ // we cannot reach this without insane mockery 160 | // D3JsRendererState -> [RenderEvent] -> [RenderEvent] -> [RenderEvent] -> undefined 161 | function rafFn(state, _eventsToAdd, currentRunningEvents, _newEvents, nowMs) { 162 | const stage = state.svg.selectAll('.stage'); 163 | const all = stage.selectAll('.line,.shape').data(currentRunningEvents, getId);//.enter().append(partial(getShape, document)); 164 | 165 | renderConfig.frameRenderer(nowMs, state, all); 166 | } 167 | 168 | function play(state, player) { 169 | return renderUtils.play(state, player, function _render(state, currentRunningEvents, newEvents, nowMs) { 170 | return renderUtils.render(state, renderConfig.cleanupFn || funtils.noop, rafFn, currentRunningEvents, newEvents, nowMs); 171 | }, renderConfig.resume || funtils.noop); 172 | } 173 | renderer.lift('play', play); 174 | renderer.lift('restart', function _restart(state, player) { 175 | const id = state.id; 176 | 177 | // ensure the DOM node for this instance is the only one visible 178 | [].map.call(state.root.getElementsByClassName(DOM_ID), function (node) { 179 | node.style.display = node.getAttribute('id') === id ? 'block' : 'none'; 180 | }); 181 | 182 | return play(state, player); 183 | }); 184 | renderer.lift('pause', renderUtils.pause); 185 | renderer.lift('stop', renderUtils.stop); 186 | renderer.lift('resize', function (state, dimension) { 187 | if (state.height === dimension.height && state.width === dimension.width) return state; 188 | return renderConfig.resize ? renderConfig.resize(state, dimension, renderer) : resize(state, dimension, renderer); 189 | }); 190 | 191 | const setupFn = function setupRenderer(midi, config) { 192 | let rendererState = renderConfig.prepDOM(midi, config); 193 | const animEvents = transformMidi(midi); 194 | 195 | rendererState = rendererState.next({ 196 | animEventsByTimeMs: animEvents, 197 | renderEvents: renderConfig.mapEvents(rendererState, animEvents) 198 | // renderEvents: groupByTime(renderConfig.mapEvents(rendererState, mapToAnimEvents(midi))) 199 | }); 200 | 201 | return renderer(rendererState); 202 | }; 203 | 204 | setupFn.DOM_ID = DOM_ID; 205 | 206 | return setupFn; 207 | } 208 | 209 | module.exports = { 210 | prepDOM: prepDOM, 211 | resize: resize, 212 | generate: generate, 213 | D3: d3 214 | }; 215 | -------------------------------------------------------------------------------- /src/renderers/three.js: -------------------------------------------------------------------------------- 1 | /** @module ThreeJsRenderer */ 2 | 'use strict'; 3 | 4 | const THREE = require('three'); 5 | const funtils = require('funtils'); 6 | const monad = funtils.monad; 7 | const renderUtils = require('./utils'); 8 | const scale = renderUtils.scale; 9 | const maxNote = renderUtils.maxNote; 10 | const minNote = renderUtils.minNote; 11 | const isNoteOnEvent = renderUtils.isNoteOnEvent; 12 | const { transformMidi } = require('../midi-transformer'); 13 | const ThreeJsRendererState = require('../data-types').ThreeJsRendererState; 14 | 15 | const DOM_ID = 'threejs'; 16 | 17 | function toggleStage(root, id) { 18 | [].map.call(root.getElementsByClassName(DOM_ID) || [], function (node) { 19 | node.style.display = node.getAttribute('id') === id ? 'block' : 'none'; 20 | }); 21 | } 22 | 23 | function genSongScales(dimension, midi) { 24 | return midi.tracks.reduce(function (scales, track, index) { 25 | if (track.events.length === 0) return scales; 26 | 27 | const trackScale = scales[index] = { 28 | x: scale.scaleLinear(), 29 | y: scale.scaleLinear(), 30 | note: scale.scaleLinear() 31 | }; 32 | 33 | const onNotes = track.events.filter(isNoteOnEvent); 34 | const highestNote = onNotes.reduce(maxNote, 0); 35 | const lowestNote = onNotes.reduce(minNote, highestNote); 36 | 37 | trackScale.y.range([25, dimension.height]); 38 | trackScale.y.domain([lowestNote, highestNote]); 39 | 40 | trackScale.x.range([25, dimension.height]); 41 | trackScale.x.domain([lowestNote, highestNote]); 42 | 43 | trackScale.note.range([50, 100]); 44 | trackScale.note.domain(trackScale.x.domain()); 45 | 46 | trackScale.hue = scale.scaleLinear().range([0,360]).domain(trackScale.x.domain()); 47 | trackScale.velocity = scale.scaleLinear().range([30,60]).domain([0, 256]); 48 | 49 | return scales; 50 | }, []); 51 | } 52 | 53 | /** 54 | * @function 55 | * @name prepDOM 56 | * @description handles initialization of DOM for renderer 57 | * @param {Midi} midi - Midi instance of song information 58 | * @param {object} config - configuration information 59 | * @param {Window} config.window - Window where rendering will take place 60 | * @param {HTMLElement} config.root - DOM Element that will hold render canvas 61 | * @param {number} dimension.width - width of the rendering area 62 | * @param {number} dimension.height - height of the renderering area 63 | * @return {ThreeJsRendererState} 64 | */ 65 | // Midi -> Config -> ThreeJsRendererState 66 | function prepDOM(midi, config) { 67 | const w = config.window; 68 | const d = w.document; 69 | const e = d.documentElement; 70 | const x = config.width || w.innerWidth || e.clientWidth; 71 | const y = config.height || w.innerHeight|| e.clientHeight; 72 | 73 | if (!x) throw new TypeError('unable to calculate width'); 74 | if (!y) throw new TypeError('unable to calculate height'); 75 | 76 | const scene = new THREE.Scene(); 77 | /* istanbul ignore next */ // not important to check both sides of this ternary 78 | const camera = new THREE.PerspectiveCamera(45, x / y, 0.1, x > y ? x*2 : y*2); 79 | const renderer = new THREE.WebGLRenderer(); 80 | 81 | renderer.setSize(x, y); 82 | 83 | const domElement = renderer.domElement; 84 | domElement.className = DOM_ID; 85 | 86 | // TODO: get a real UUID implementation.. 87 | const id = domElement.getAttribute('id') || Date.now().toString().split('').map(function (char) { return (Math.random() * char).toString(16); }).join(''); 88 | domElement.setAttribute('id', id); 89 | 90 | toggleStage(config.root, id); 91 | 92 | config.root.appendChild(domElement); 93 | 94 | const state = new ThreeJsRendererState({ 95 | id: id, 96 | root: config.root, 97 | window: w, 98 | width: x, 99 | height: y, 100 | scales: genSongScales({ width: x, height: y }, midi), 101 | camera: camera, 102 | scene: scene, 103 | renderer: renderer, 104 | THREE: THREE, 105 | animEventsByTimeMs: [], 106 | }); 107 | 108 | return state; 109 | } 110 | 111 | /** 112 | * @function 113 | * @name resize 114 | * @description deals with resizing of the browser window 115 | * @param {ThreeJsRendererState} state - current renderer state 116 | * @param {object} dimension - dimensions of render area 117 | * @param {number} dimension.width 118 | * @param {number} dimension.height 119 | * @return {ThreeJsRendererState} 120 | */ 121 | // ThreeJsRendererState -> {width,height} -> ThreeJsRendererState 122 | function resize(state, dimension) { 123 | const renderer = state.renderer; 124 | const camera = state.camera; 125 | 126 | camera.aspect = dimension.width / dimension.height; 127 | camera.updateProjectionMatrix(); 128 | 129 | renderer.setSize(dimension.width, dimension.height); 130 | 131 | return state.next({ 132 | width: dimension.width, 133 | height: dimension.height, 134 | renderer: renderer 135 | }); 136 | } 137 | 138 | /** 139 | * @function 140 | * @name cleanup 141 | * @description removes any object from the scene 142 | * @param {ThreeJsRendererState} state - current renderer state 143 | * @param {RenderEvent} currentRunningEvents[] - array of RenderEvents currently active 144 | * @param {RenderEvent} expiredEvents[] - array of RenderEvents that are no longer active and should be cleaned up 145 | * @return {undefined} 146 | */ 147 | // ThreeJsRendererState -> [RenderEvent] -> [RenderEvent] -> undefined 148 | function cleanup(state, currentRunningEvents, expiredEvents/*, nowMs */) { 149 | // TODO: this is not currently being used...need an example that uses it... 150 | /*eslint-disable no-console*/ 151 | expiredEvents.map(function (event) { 152 | const obj = state.scene.getObjectByName(event.id); 153 | 154 | if (obj) { 155 | state.scene.remove(obj); 156 | if (obj.dispose) { 157 | obj.dispose(); 158 | } 159 | } else { 160 | console.error('NO OBJ', event.id); 161 | } 162 | }); 163 | } 164 | 165 | /** 166 | * @function 167 | * @name generate 168 | * @description generator to create ThreeJsRenderer 169 | * @param {object} renderConfig - configuration information for setup 170 | * @param {ThreeJsRenderer~frameRenderCb} frameRenderer - callback for rendering events 171 | * @param {ThreeJsRenderer~cleanupCb} cleanupFn - callback for cleaning up THREEJS 172 | * @return {ThreeJsRenderer~generateReturnFn} 173 | */ 174 | /** 175 | * @name frameRenderCb 176 | * @callback 177 | * @description callback for actual rendering of frame 178 | * @param {ThreeJsRenderEvent} eventsToAdd[] - events that are queued up to be rendered in the next frame 179 | * @param {THREEJS~Scene} scene - ThreeJS scene events should be renderered in 180 | * @param {THREEJS~Camera} camera - ThreeJS camera for given scene 181 | * @param {THREEJS} THREE - ThreeJS 182 | * @return undefined 183 | */ 184 | /** 185 | * @name ThreeJsRenderer~cleanupCb 186 | * @callback 187 | * @description callback to allow for customized cleanup of expired events 188 | * @param {ThreeJsRenderState} state - current state of renderer 189 | * @param {ThreeJsRendererEvent} currentRunningEvents[] - ThreeJsRenderEvents currently in animation 190 | * @param {ThreeJsRendererEvent} expiredEvents[] - ThreeJsRenderEvents ready to be cleaned up 191 | * @param {number} nowMs - current render time in milliseconds 192 | * @return undefined 193 | */ 194 | /** 195 | * @name generateReturnFn 196 | * @function 197 | * @description function returned to user for creating instance of ThreeJsRenderer 198 | * @param {Midi} midi - Midi data to be renderered 199 | * @param {object} config - configuration information 200 | * @param {Window} config.window - Window where rendering will take place 201 | * @param {HTMLElement} config.root - DOM Element that will hold render canvas 202 | * @param {number} dimension.width - width of the rendering area 203 | * @param {number} dimension.height - height of the renderering area 204 | * @return ThreeJsRenderer 205 | */ 206 | // Config -> (Midi -> Config -> Renderer) 207 | function generate(renderConfig) { 208 | const renderer = monad(); 209 | 210 | renderer.DOM_ID = DOM_ID; 211 | 212 | /* istanbul ignore next */ // we cannot reach this without insane mockery 213 | // ThreeJsRendererState -> [RenderEvent] -> [RenderEvent] -> undefined 214 | function rafFn(state, eventsToAdd, _currentEvents, _newEvents, nowMs) { 215 | const shapes = renderConfig.frameRenderer(nowMs, eventsToAdd, state.scene, state.camera, THREE); 216 | const geometry = new THREE.Object3D(); 217 | 218 | shapes.map(function (shape) { 219 | geometry.add(shape); 220 | }); 221 | 222 | state.scene.add(geometry); 223 | 224 | state.renderer.render(state.scene, state.camera); 225 | } 226 | 227 | function play(state, player) { 228 | return renderUtils.play(state, player, function _render(state, currentRunningEvents, newEvents, nowMs) { 229 | return renderUtils.render(state, renderConfig.cleanupFn, rafFn, currentRunningEvents, newEvents, nowMs); 230 | }, renderConfig.resumeFn || funtils.noop); 231 | } 232 | 233 | renderer.lift('play', play); 234 | renderer.lift('restart', function _restart(state, player) { 235 | toggleStage(state.root, state.id); 236 | 237 | return play(state, player); 238 | }); 239 | renderer.lift('pause', renderUtils.pause); 240 | renderer.lift('stop', renderUtils.stop); 241 | renderer.lift('resize', resize); 242 | 243 | const setupFn = function setupRenderer(midi, config) { 244 | let rendererState = renderConfig.prepDOM(midi, config); 245 | const animEvents = transformMidi(midi); 246 | 247 | rendererState = rendererState.next({ 248 | renderEvents: renderConfig.mapEvents(rendererState, animEvents) 249 | // renderEvents: groupByTime(renderConfig.mapEvents(rendererState, mapToAnimEvents(midi))) 250 | }); 251 | 252 | return renderer(rendererState); 253 | }; 254 | 255 | setupFn.DOM_ID = DOM_ID; 256 | 257 | return setupFn; 258 | } 259 | 260 | module.exports = { 261 | prepDOM: prepDOM, 262 | cleanup: cleanup, 263 | resize: resize, 264 | generate: generate, 265 | THREE: THREE 266 | }; 267 | -------------------------------------------------------------------------------- /src/renderers/utils.js: -------------------------------------------------------------------------------- 1 | /** @module RenderUtils */ 2 | 3 | 'use strict'; 4 | 5 | const _ = require('lodash'); 6 | const d3 = require('d3'); 7 | const { transformMidi } = require('../midi-transformer'); 8 | 9 | /** @constant 10 | * @type {number} 11 | * @name MAX_RAF_DELTA_MS 12 | * @default 16 13 | */ 14 | const MAX_RAF_DELTA_MS = 16; 15 | 16 | module.exports = function closure() { 17 | // Some things we need to keep track of between play/pause calls 18 | let lastRafId = null; 19 | let lastPlayheadTimeMs = 0; 20 | let currentRunningEvents = []; 21 | 22 | /** 23 | * @name play 24 | * @function 25 | * @description Put visualizer in "play" state (where audio player is playing and animations are running) 26 | * 27 | * @param {RendererState} state - current monad state 28 | * @param {AudioPlayer} player - audio player used for audio playback we are syncing to 29 | * @param {RenderUtils~render} renderFn - callback for actual rendering 30 | * @param {RenderUtils~resume} resumeFn - callback for resuming playback after stopping 31 | * 32 | * @return {RendererState} - new monad state 33 | */ 34 | // (RendererState -> AudioPlayer -> [RenderEvent] -> [RenderEvent]) -> RendererState -> Int -> (RendererState -> undefined) -> RendererState 35 | function play(state, player, renderFn, resumeFn) { 36 | const stateSnapshot = state.copy(); 37 | const raf = stateSnapshot.window.requestAnimationFrame; 38 | const songLengthMs = player.lengthMs; 39 | 40 | function animate(/* now */) { 41 | if (!player.isPlaying) { 42 | window.cancelAnimationFrame(lastRafId); 43 | lastRafId = null; 44 | return; 45 | } 46 | 47 | const nowMs = player.getPlayheadTime(); 48 | 49 | if (nowMs >= songLengthMs) { 50 | window.cancelAnimationFrame(lastRafId); 51 | lastRafId = null; 52 | return; 53 | } 54 | 55 | const playDelta = nowMs - lastPlayheadTimeMs; 56 | 57 | if (nowMs < lastPlayheadTimeMs || playDelta > MAX_RAF_DELTA_MS * 10) { 58 | resumeFn(state, nowMs); 59 | currentRunningEvents = []; 60 | lastPlayheadTimeMs = nowMs; 61 | } 62 | 63 | // NOTE: using for loop here for performance reasons.. 64 | let eventKeys = []; 65 | for (const eventTimeMs in stateSnapshot.renderEvents) { 66 | if (lastPlayheadTimeMs <= eventTimeMs && eventTimeMs <= nowMs){ 67 | eventKeys.push(eventTimeMs); 68 | } 69 | } 70 | 71 | if (eventKeys.length > 0) { 72 | const events = _.reduce(eventKeys, (events, key) => events.concat(stateSnapshot.renderEvents[key]), []); 73 | 74 | /* istanbul ignore else */ 75 | if (events.length > 0) { 76 | currentRunningEvents = renderFn(state, currentRunningEvents, events, nowMs); 77 | } 78 | } 79 | 80 | lastPlayheadTimeMs = nowMs; 81 | lastRafId = raf(animate); 82 | } 83 | 84 | lastRafId = raf(animate); 85 | 86 | return state; 87 | } 88 | 89 | /** 90 | * @name pause 91 | * @function 92 | * @description Put visualizer in "paused" state (where audio player is paused and animations are not running) 93 | * 94 | * @param {RendererState} state - current monad state 95 | * 96 | * @return {RendererState} - new monad state 97 | */ 98 | // RendererState -> RendererState 99 | function pause(state) { 100 | state.window.cancelAnimationFrame(lastRafId); 101 | return state; 102 | } 103 | 104 | /** 105 | * @name stop 106 | * @function 107 | * @description Put visualizer in "stopped" state (where audio player is stopped and animations are not running) 108 | * 109 | * @param {RendererState} state - current monad state 110 | * 111 | * @return {RendererState} - new monad state 112 | */ 113 | // RendererState -> RendererState 114 | function stop(state) { 115 | currentRunningEvents = []; 116 | lastPlayheadTimeMs = 0; 117 | return pause(state); 118 | } 119 | 120 | /** 121 | * @name transformEvents 122 | * @function 123 | * @description Applies given track transforms to animation events 124 | * 125 | * @param {RendererState} state - state monad 126 | * @param {function[]} trackTransforms - callback functions (TODO: document) 127 | * @param {AnimEvent[]} animEvents - given animation events to transform 128 | * 129 | * @return {RenderEvent[]} array of transformed renderEvents 130 | */ 131 | // RendererState -> [(RendererState -> AnimEvent -> [RenderEvent])] -> [AnimEvent] -> [RenderEvent] 132 | function transformEvents(state, trackTransformers, animEvents) { 133 | // TODO: if we want to move to groupByTime(render.mapEvents(mapToAnimEvents(midi))) 134 | // we have to figure out why this function returns undefined values 135 | // (also, the above approach appears to be very time-consuming, so we may need something else...) 136 | const renderEvents = {}; 137 | 138 | // NOTE: we switched to for..in loops here as that turned out to be the fastest way to do this 139 | for (const timeInMs in animEvents) { 140 | renderEvents[timeInMs] = renderEvents[timeInMs] || []; 141 | for (const eventIdx in animEvents[timeInMs]) { 142 | const event = animEvents[timeInMs][eventIdx]; 143 | const transformFn = trackTransformers[event.track]; 144 | if (transformFn) { 145 | // we have to add to the events to prevent losing events not returned in the transform... 146 | renderEvents[timeInMs].push.apply(renderEvents[timeInMs], transformFn(state, event)); 147 | } else { 148 | /*eslint-disable no-console*/ 149 | console.error('No transform for track "' + event.track + '"'); 150 | } 151 | } 152 | } 153 | 154 | return renderEvents; 155 | } 156 | 157 | /** 158 | * @name mapEvents 159 | * @function 160 | * @description Map over given Midi data, transforming MidiEvents into RenderEvents 161 | * 162 | * @param {RendererState} state - current monad state 163 | * @param {Midi} midi - midi data to map to RenderEvents 164 | * @param {object} config - configuration data 165 | * 166 | * @return {RendererState} - new monad state 167 | */ 168 | // RendererState -> Midi -> Config -> RendererState 169 | function mapEvents(rendererState, midi, config) { 170 | const animEvents = transformMidi(midi); 171 | 172 | return rendererState.next({ 173 | animEventsByTimeMs: animEvents, 174 | renderEvents: transformEvents(rendererState, config.transformers, animEvents) 175 | }); 176 | } 177 | 178 | /** 179 | * @name maxNote 180 | * @function 181 | * @description Compare given note with note in given RenderEvent, returning whichever is larger 182 | * 183 | * @param {number} currentMaxNote - value of current "max" note 184 | * @param {RenderEvent} event - RenderEvent containing note to compare 185 | * 186 | * @return {number} - largest of two notes 187 | */ 188 | // Int -> MidiEvent -> Int 189 | function maxNote(currMaxNote, event) { 190 | return currMaxNote > event.note ? currMaxNote : event.note; 191 | } 192 | 193 | /** 194 | * @name minNote 195 | * @function 196 | * @description Compare given note with note in given RenderEvent, returning whichever is smaller 197 | * 198 | * @param {number} currentMinNote - value of current "min" note 199 | * @param {RenderEvent} event - RenderEvent containing note to compare 200 | * 201 | * @return {number} - smallest of two notes 202 | */ 203 | // Int -> MidiEvent -> Int 204 | function minNote(currMinNote, event) { 205 | return currMinNote < event.note ? currMinNote : event.note; 206 | } 207 | 208 | /** 209 | * @name isNoteToggleEvent 210 | * @function 211 | * @description Predicate to test whether given RenderEvent is for a note on/off event 212 | * 213 | * @param {RenderEvent} event - RenderEvent to test 214 | * 215 | * @return {boolean} - is it a note on/off event 216 | */ 217 | // MidiEvent -> Boolean 218 | function isNoteToggleEvent(event) { 219 | return event.type === 'note'; 220 | } 221 | 222 | /** 223 | * @name isNoteOnEvent 224 | * @function 225 | * @description Predicate to test whether given RenderEvent is for a note on event 226 | * 227 | * @param {RenderEvent} event - RenderEvent to test 228 | * 229 | * @return {boolean} - is it a note on event 230 | */ 231 | // MidiEvent -> Boolean 232 | function isNoteOnEvent(event) { 233 | return isNoteToggleEvent(event) && event.subtype === 'on'; 234 | } 235 | 236 | /** 237 | * @name render 238 | * @function 239 | * @description render function 240 | * 241 | * @param {RendererState} state - monad state 242 | * @param {function} cleanupFn - callback to remove expired animation artifacts 243 | * @param {function} rafFn - RAF callback to do actual animation 244 | * @param {RenderEvent[]} currentRunningEvents - RenderEvents currently being animated 245 | * @param {RenderEvent[]} renderEvents - new RenderEvents to animate 246 | * @param {number} nowMs - current time in milliseconds 247 | * 248 | * @return {RenderEvent[]} - active running render events for this render call 249 | */ 250 | // RendererState -> (RendererState -> [RenderEvent] -> undefined) -> (RendererState -> [RenderEvent] -> undefined) -> [RenderEvent] -> [RenderEvent] -> Int -> [RenderEvent] 251 | function render(state, cleanupFn, rafFn, currentRunningEvents, renderEvents, nowMs) { 252 | let expiredEvents = []; 253 | const eventsToAdd = []; 254 | const nowMicroSec = nowMs * 1000; 255 | 256 | renderEvents.forEach(function (event) { 257 | const id = event.id; 258 | const matchIndices = currentRunningEvents.reduce(function (matchIndices, event, index) { 259 | return event.id === id ? matchIndices.concat([index]) : matchIndices; 260 | }, []); 261 | 262 | switch (event.subtype) { 263 | case 'on': 264 | if (matchIndices.length === 0) { 265 | eventsToAdd.push(event); 266 | currentRunningEvents.push(event); 267 | } 268 | break; 269 | case 'off': 270 | expiredEvents = expiredEvents.concat(currentRunningEvents.filter(function (_elem, index) { 271 | return matchIndices.indexOf(index) > -1; 272 | })); 273 | 274 | currentRunningEvents = currentRunningEvents.filter(function (_elem, index) { 275 | return -1 === matchIndices.indexOf(index); 276 | }); 277 | break; 278 | case 'timer': 279 | eventsToAdd.push(event); 280 | break; 281 | default: 282 | console.error('unknown render event subtype "' + event.subtype + '"'); 283 | } 284 | }); 285 | 286 | expiredEvents = expiredEvents.concat(currentRunningEvents.filter(function (event) { return event.startTimeMicroSec > nowMicroSec; })); 287 | 288 | const timestampMs = state.window.performance.now(); 289 | 290 | state.window.requestAnimationFrame(function (nowMs) { 291 | const deltaMs = nowMs - timestampMs; 292 | 293 | cleanupFn(state, currentRunningEvents, expiredEvents, nowMs); 294 | 295 | if (deltaMs < MAX_RAF_DELTA_MS) { 296 | // TODO: should we be passing the state in, or just what is needed? 297 | // this is happening outside of "state" (i.e. in an async "set-and-forget" animation renderer), 298 | // so perhaps this should not include state?!? 299 | rafFn(state, eventsToAdd, currentRunningEvents, [], nowMs); 300 | } else { 301 | console.error('skipping render due to ' + deltaMs + 'ms delay'); 302 | } 303 | }); 304 | 305 | return currentRunningEvents; 306 | } 307 | 308 | return { 309 | mapEvents: mapEvents, 310 | play: play, 311 | pause: pause, 312 | stop: stop, 313 | 314 | render: render, 315 | 316 | transformEvents: transformEvents, 317 | 318 | maxNote: maxNote, 319 | minNote: minNote, 320 | isNoteOnEvent: isNoteOnEvent, 321 | scale: d3, 322 | 323 | MAX_RAF_DELTA: MAX_RAF_DELTA_MS 324 | }; 325 | }(); 326 | -------------------------------------------------------------------------------- /test/audio-player.spec.js: -------------------------------------------------------------------------------- 1 | /* jshint expr: true */ 2 | /* globals describe: true, beforeEach: true, afterEach: true, it: true */ 3 | 'use strict'; 4 | 5 | const chai = require('chai'); 6 | const expect = chai.expect; 7 | const sinon = require('sinon'); 8 | 9 | const AudioPlayer = require('../src/audio-player.js'); 10 | 11 | function setupMockAudioSource(mockAudioContext) { 12 | const mockAudioSource = { 13 | connect: sinon.spy(), 14 | start: sinon.spy(), 15 | stop: sinon.spy() 16 | }; 17 | 18 | if (mockAudioContext.createBufferSource) mockAudioContext.createBufferSource.reset(); 19 | 20 | mockAudioContext.createBufferSource = sinon.stub(); 21 | mockAudioContext.createBufferSource.onCall(0).returns(mockAudioSource); 22 | 23 | return mockAudioSource; 24 | } 25 | 26 | function setupMockAudioContext(mockAudioContext) { 27 | mockAudioContext.decodeAudioData.callsArgWithAsync(1, {}); 28 | } 29 | 30 | function loadPlayer(audioPlayer, callback, mockAudioContext) { 31 | const mockAudioSource = setupMockAudioSource(mockAudioContext); 32 | 33 | setupMockAudioContext(mockAudioContext); 34 | 35 | audioPlayer.loadData({}, callback); 36 | 37 | return mockAudioSource; 38 | } 39 | 40 | describe('AudioPlayer', function() { 41 | 42 | describe('#getAudioContextFromWindow', function () { 43 | it('should throw if no window passed', function (done) { 44 | expect(function () { AudioPlayer.getAudioContextFromWindow(); }).to.be.throw(TypeError); 45 | done(); 46 | }); 47 | 48 | it('should throw if no window passed', function (done) { 49 | expect(function () { AudioPlayer.getAudioContextFromWindow(); }).to.be.throw(TypeError); 50 | done(); 51 | }); 52 | 53 | describe('when there is a msAudioContext', function () { 54 | let mockWindow; 55 | 56 | beforeEach(function (done) { 57 | mockWindow = { 58 | msAudioContext: sinon.spy() 59 | }; 60 | 61 | done(); 62 | }); 63 | 64 | afterEach(function (done) { 65 | mockWindow = null; 66 | done(); 67 | }); 68 | 69 | it('should return our msAudioContext', function (done) { 70 | expect(AudioPlayer.getAudioContextFromWindow(mockWindow)).to.equal(mockWindow.msAudioContext); 71 | done(); 72 | }); 73 | 74 | describe('when there is an oAudioContext', function () { 75 | beforeEach(function (done) { 76 | mockWindow.oAudioContext = sinon.spy(); 77 | done(); 78 | }); 79 | 80 | it('should return our oAudioContext', function (done) { 81 | expect(AudioPlayer.getAudioContextFromWindow(mockWindow)).to.equal(mockWindow.oAudioContext); 82 | done(); 83 | }); 84 | 85 | describe('when there is a mozAudioContext', function () { 86 | beforeEach(function (done) { 87 | mockWindow.mozAudioContext = sinon.spy(); 88 | done(); 89 | }); 90 | 91 | it('should return our mozAudioContext', function (done) { 92 | expect(AudioPlayer.getAudioContextFromWindow(mockWindow)).to.equal(mockWindow.mozAudioContext); 93 | done(); 94 | }); 95 | 96 | describe('when there is a webkitAudioContext', function () { 97 | beforeEach(function (done) { 98 | mockWindow.webkitAudioContext = sinon.spy(); 99 | done(); 100 | }); 101 | 102 | it('should return our webkitAudioContext', function (done) { 103 | expect(AudioPlayer.getAudioContextFromWindow(mockWindow)).to.equal(mockWindow.webkitAudioContext); 104 | done(); 105 | }); 106 | 107 | describe('when there is a native AudioContext', function () { 108 | beforeEach(function (done) { 109 | mockWindow.AudioContext = sinon.spy(); 110 | done(); 111 | }); 112 | 113 | it('should return our native AudioContext', function (done) { 114 | expect(AudioPlayer.getAudioContextFromWindow(mockWindow)).to.equal(mockWindow.AudioContext); 115 | done(); 116 | }); 117 | }); 118 | }); 119 | }); 120 | }); 121 | }); 122 | }); 123 | 124 | describe('constructing without an AudioContext', function() { 125 | it('should throw an error if we try to construct without passing any params (meaning there is no audio context)', function (done) { 126 | expect(function () { new AudioPlayer(); }).to.throw(TypeError); 127 | done(); 128 | }); 129 | 130 | it('should throw an error if we try to construct without passing an audio context', function (done) { 131 | expect(function () { new AudioPlayer({}); }).to.throw(TypeError); 132 | done(); 133 | }); 134 | 135 | it('should throw an error if we try to construct with a window that does not have an audio context', function (done) { 136 | expect(function () { new AudioPlayer({ window: {} }); }).to.throw(TypeError); 137 | done(); 138 | }); 139 | }); 140 | 141 | describe('construction with an AudioContext', function() { 142 | let MockContextClass, mockAudioContext, audioPlayer; 143 | 144 | beforeEach(function(done) { 145 | mockAudioContext = { 146 | currentTime: 0, 147 | decodeAudioData: sinon.stub() 148 | }; 149 | 150 | MockContextClass = sinon.stub(); 151 | MockContextClass.returns(mockAudioContext); 152 | 153 | audioPlayer = new AudioPlayer({ 154 | window: { AudioContext: MockContextClass } 155 | }); 156 | 157 | done(); 158 | }); 159 | 160 | describe('construction', function() { 161 | 162 | it('should not be loaded', function(done) { 163 | expect(audioPlayer.isLoaded).to.be.false; 164 | done(); 165 | }); 166 | 167 | it('should not be loading', function(done) { 168 | expect(audioPlayer.isLoading).to.be.false; 169 | done(); 170 | }); 171 | }); 172 | 173 | describe('#loadData', function() { 174 | 175 | beforeEach(function(done) { 176 | 177 | loadPlayer(audioPlayer, function() { 178 | setTimeout(done, 0); 179 | }, mockAudioContext); 180 | 181 | expect(audioPlayer.isLoading).to.be.true; 182 | expect(audioPlayer.isLoaded).to.be.false; 183 | }); 184 | 185 | it('should no longer be loading', function(done) { 186 | expect(audioPlayer.isLoading).to.be.false; 187 | done(); 188 | }); 189 | 190 | it('should reflect that data is loaded', function(done) { 191 | expect(audioPlayer.isLoaded).to.be.true; 192 | done(); 193 | }); 194 | 195 | it('should pass data to context for decoding', function(done) { 196 | expect(mockAudioContext.decodeAudioData.called).to.be.true; 197 | done(); 198 | }); 199 | 200 | describe('error handling', function() { 201 | 202 | it('should throw an error if no audio source is provided', function(done) { 203 | expect(function validateAudioSourceRequired() { 204 | audioPlayer.loadData(); 205 | }).to.throw(Error); 206 | 207 | done(); 208 | }); 209 | 210 | it('should throw an error if no callback is provided', function(done) { 211 | expect(function validateAudioSourceRequired() { 212 | audioPlayer.loadData({}); 213 | }).to.throw(Error); 214 | 215 | done(); 216 | }); 217 | 218 | it('should callback with an error if we are already loading', function(done) { 219 | setupMockAudioContext(mockAudioContext); 220 | 221 | audioPlayer.loadData({}, function() {}); 222 | 223 | audioPlayer.loadData({}, function(e) { 224 | expect(e).to.equal('Already loading audio data'); 225 | done(); 226 | }); 227 | }); 228 | 229 | it('should callback with an error if AudioContext throws an error decoding the audio source', function(done) { 230 | mockAudioContext.decodeAudioData = sinon.stub(); 231 | mockAudioContext.decodeAudioData.throws(); 232 | 233 | audioPlayer.loadData({}, function(e) { 234 | expect(e).to.match(/error decoding audio/); 235 | done(); 236 | }); 237 | }); 238 | }); 239 | }); 240 | 241 | describe('#play', function() { 242 | 243 | describe('when not yet loaded', function() { 244 | let playReturn; 245 | 246 | beforeEach(function(done) { 247 | setupMockAudioSource(mockAudioContext); 248 | 249 | playReturn = audioPlayer.play(); 250 | done(); 251 | }); 252 | 253 | it('should return false', function(done) { 254 | expect(playReturn).to.be.false; 255 | done(); 256 | }); 257 | 258 | it('should not attempt to create an AudioBuffer', function(done) { 259 | expect(mockAudioContext.createBufferSource.called).to.be.false; 260 | done(); 261 | }); 262 | }); 263 | 264 | describe('when already playing', function() { 265 | let playReturn; 266 | 267 | beforeEach(function(done) { 268 | loadPlayer(audioPlayer, function() { 269 | audioPlayer.play(); 270 | 271 | mockAudioContext.createBufferSource = sinon.spy(); 272 | 273 | setTimeout(done, 0); 274 | 275 | playReturn = audioPlayer.play(); 276 | }, mockAudioContext); 277 | }); 278 | 279 | it('should return true', function(done) { 280 | expect(playReturn).to.be.true; 281 | done(); 282 | }); 283 | 284 | it('should not attempt to create an AudioBuffer', function(done) { 285 | expect(mockAudioContext.createBufferSource.called).to.be.false; 286 | done(); 287 | }); 288 | }); 289 | 290 | describe('when loaded', function() { 291 | let playReturn, mockAudioSource; 292 | 293 | beforeEach(function(done) { 294 | mockAudioSource = loadPlayer(audioPlayer, function() { 295 | setTimeout(done, 0); 296 | 297 | playReturn = audioPlayer.play(); 298 | }, mockAudioContext); 299 | }); 300 | 301 | it('should return true', function(done) { 302 | expect(playReturn).to.be.true; 303 | done(); 304 | }); 305 | 306 | it('should attempt to create an AudioBuffer', function(done) { 307 | expect(mockAudioContext.createBufferSource.called).to.be.true; 308 | done(); 309 | }); 310 | 311 | it('should call pause when audio source ends', function (done) { 312 | audioPlayer.pause = sinon.spy(); 313 | mockAudioSource.onended(); 314 | expect(audioPlayer.pause.called).to.be.true; 315 | done(); 316 | }); 317 | }); 318 | }); 319 | 320 | describe('#pause', function() { 321 | 322 | describe('when not yet loaded', function() { 323 | let pauseReturn; 324 | 325 | beforeEach(function(done) { 326 | pauseReturn = audioPlayer.pause(); 327 | done(); 328 | }); 329 | 330 | it('should return false', function(done) { 331 | expect(pauseReturn).to.be.false; 332 | done(); 333 | }); 334 | }); 335 | 336 | describe('when not playing', function() { 337 | let pauseReturn; 338 | 339 | beforeEach(function(done) { 340 | loadPlayer(audioPlayer, function() { 341 | setTimeout(done, 0); 342 | 343 | pauseReturn = audioPlayer.pause(); 344 | }, mockAudioContext); 345 | }); 346 | 347 | it('should return true', function(done) { 348 | expect(pauseReturn).to.be.true; 349 | done(); 350 | }); 351 | }); 352 | 353 | describe('when loaded and playing', function() { 354 | let pauseReturn, mockAudioSource; 355 | 356 | beforeEach(function(done) { 357 | mockAudioSource = loadPlayer(audioPlayer, function(e) { 358 | expect(e).to.be.null; 359 | audioPlayer.play(); 360 | pauseReturn = audioPlayer.pause(); 361 | done(); 362 | }, mockAudioContext); 363 | }); 364 | 365 | it('should stop the audio source', function(done) { 366 | expect(mockAudioSource.stop.called).to.be.true; 367 | done(); 368 | }); 369 | }); 370 | }); 371 | 372 | describe('#getPlayheadTime', function() { 373 | 374 | it('should return zero if data has not yet loaded', function(done) { 375 | expect(audioPlayer.getPlayheadTime()).to.equal(0); 376 | done(); 377 | }); 378 | 379 | describe('after data loaded', function() { 380 | 381 | beforeEach(function(done) { 382 | mockAudioContext.decodeAudioData.callsArgWith(1, { 383 | duration: 60000 384 | }); 385 | audioPlayer.loadData({}, done); 386 | }); 387 | 388 | it('should indicate we are not playing', function(done) { 389 | expect(audioPlayer.isPlaying).to.be.false; 390 | done(); 391 | }); 392 | 393 | it('should start at zero', function(done) { 394 | expect(audioPlayer.getPlayheadTime()).to.equal(0); 395 | done(); 396 | }); 397 | 398 | describe('playing', function() { 399 | let mockAudioSource; 400 | 401 | beforeEach(function(done) { 402 | mockAudioSource = setupMockAudioSource(mockAudioContext); 403 | 404 | audioPlayer.play(); 405 | 406 | done(); 407 | }); 408 | 409 | it('should indicate we are playing', function(done) { 410 | expect(audioPlayer.isPlaying).to.be.true; 411 | done(); 412 | }); 413 | 414 | describe('after some period of playback', function() { 415 | 416 | beforeEach(function(done) { 417 | mockAudioContext.currentTime = 10; // 10s into play 418 | 419 | done(); 420 | }); 421 | 422 | it('should report playback in milliseconds', function(done) { 423 | expect(audioPlayer.getPlayheadTime()).to.equal(10000); 424 | done(); 425 | }); 426 | 427 | describe('after first pause', function() { 428 | 429 | beforeEach(function(done) { 430 | mockAudioSource = setupMockAudioSource(mockAudioContext); 431 | audioPlayer.pause(); 432 | mockAudioContext.currentTime = 20; // 20s of time elapsed 433 | audioPlayer.play(); // start at last pause spot 434 | mockAudioContext.currentTime = 25; // add 5s more playback 435 | 436 | done(); 437 | }); 438 | 439 | it('should not report elapsed time, but only play time', function(done) { 440 | expect(audioPlayer.getPlayheadTime()).to.equal(15000); 441 | done(); 442 | }); 443 | 444 | describe('after second pause', function() { 445 | 446 | beforeEach(function(done) { 447 | mockAudioSource = setupMockAudioSource(mockAudioContext); 448 | audioPlayer.pause(); 449 | mockAudioContext.currentTime = 30; // 30s of time elapsed 450 | audioPlayer.play(); // start at last pause spot 451 | mockAudioContext.currentTime = 35; // add 5s more playback 452 | 453 | done(); 454 | }); 455 | 456 | it('should not report elapsed time, but only play time', function(done) { 457 | expect(audioPlayer.getPlayheadTime()).to.equal(20000); 458 | done(); 459 | }); 460 | }); 461 | }); 462 | }); 463 | }); 464 | }); 465 | }); 466 | }); 467 | }); 468 | 469 | -------------------------------------------------------------------------------- /test/data-types.spec.js: -------------------------------------------------------------------------------- 1 | /* jshint expr: true */ 2 | /* globals describe: true, beforeEach: true, afterEach: true, it: true */ 3 | 'use strict'; 4 | 5 | const chai = require('chai'); 6 | const expect = chai.expect; 7 | const sinon = require('sinon'); 8 | 9 | const types = require('../src/data-types'); 10 | const MidiVisualizerState = types.MidiVisualizerState; 11 | const RendererState = types.RendererState; 12 | const D3RendererState = types.D3RendererState; 13 | const ThreeJsRendererState = types.ThreeJsRendererState; 14 | const AnimEvent = types.AnimEvent; 15 | const RenderEvent = types.RenderEvent; 16 | const D3RenderEvent = types.D3RenderEvent; 17 | const ThreeJsRenderEvent = types.ThreeJsRenderEvent; 18 | 19 | describe('data-types', function() { 20 | 21 | describe('MidiVisualizerState', function() { 22 | let midiVisualizerState; 23 | 24 | describe('no params instantiation', function() { 25 | it('should throw an error regarding a missing "audioPlayer"', function(done) { 26 | expect(function () { 27 | midiVisualizerState = new MidiVisualizerState(); 28 | }).to.throw(/audioPlayer/); 29 | 30 | done(); 31 | }); 32 | 33 | it('should throw an error regarding a missing "renderer"', function(done) { 34 | expect(function () { 35 | midiVisualizerState = new MidiVisualizerState({ audioPlayer: sinon.spy() }); 36 | }).to.throw(/renderer/); 37 | 38 | done(); 39 | }); 40 | }); 41 | 42 | describe('empty params instantiation', function() { 43 | it('should throw an error regarding a missing "audioPlayer"', function(done) { 44 | expect(function () { 45 | midiVisualizerState = new MidiVisualizerState(); 46 | }).to.throw(/audioPlayer/); 47 | 48 | done(); 49 | }); 50 | }); 51 | 52 | describe('full params instantiation', function() { 53 | beforeEach(function(done) { 54 | midiVisualizerState = new MidiVisualizerState({ 55 | isPlaying: true, 56 | renderer: {}, 57 | audioPlayer: {}, 58 | midi: {}, 59 | animEventsByTimeMs: { 0: [] } 60 | }); 61 | 62 | done(); 63 | }); 64 | 65 | afterEach(function(done) { 66 | midiVisualizerState = null; 67 | done(); 68 | }); 69 | 70 | it('should have an audioPlayer', function(done) { 71 | expect(midiVisualizerState.audioPlayer).not.to.be.undefined; 72 | done(); 73 | }); 74 | 75 | it('should have a renderer', function(done) { 76 | expect(midiVisualizerState.renderer).not.to.be.undefined; 77 | done(); 78 | }); 79 | 80 | it('should be playing', function(done) { 81 | expect(midiVisualizerState.isPlaying).to.be.true; 82 | done(); 83 | }); 84 | 85 | it('should have no animEvents', function(done) { 86 | expect(midiVisualizerState.animEventsByTimeMs).to.have.eql({ 0: [] }); 87 | done(); 88 | }); 89 | }); 90 | }); 91 | 92 | describe('RendererState', function() { 93 | let rendererState; 94 | 95 | describe('no params instantiation', function() { 96 | it('should throw a TypeError', function (done) { 97 | expect(function () { new RendererState(); }).to.throw(TypeError); 98 | done(); 99 | }); 100 | }); 101 | 102 | describe('empty params instantiation', function() { 103 | it('should throw a TypeError', function (done) { 104 | expect(function () { new RendererState({}); }).to.throw(TypeError); 105 | done(); 106 | }); 107 | }); 108 | 109 | describe('missing id param instantiation', function() { 110 | it('should throw a TypeError', function (done) { 111 | expect(function () { 112 | new RendererState({ 113 | root: {}, 114 | width: 100, 115 | height: 100, 116 | renderEvents: [], 117 | scales: [], 118 | window: { document: {} }, 119 | animEventsByTimeMs: [], 120 | }); 121 | }).to.throw(TypeError); 122 | 123 | done(); 124 | }); 125 | }); 126 | 127 | describe('missing root param instantiation', function() { 128 | it('should throw a TypeError', function (done) { 129 | expect(function () { 130 | new RendererState({ 131 | id: 'TEST-ID', 132 | window: {}, 133 | width: 100, 134 | height: 100, 135 | renderEvents: [], 136 | scales: [], 137 | animEventsByTimeMs: [], 138 | }); 139 | }).to.throw(TypeError); 140 | 141 | done(); 142 | }); 143 | }); 144 | 145 | describe('missing window param instantiation', function() { 146 | it('should throw a TypeError', function (done) { 147 | expect(function () { 148 | new RendererState({ 149 | id: 'TEST-ID', 150 | root: {}, 151 | width: 100, 152 | height: 100, 153 | renderEvents: [], 154 | scales: [], 155 | animEventsByTimeMs: [], 156 | }); 157 | }).to.throw(TypeError); 158 | 159 | done(); 160 | }); 161 | }); 162 | 163 | describe('window param is missing document property instantiation', function() { 164 | it('should throw a TypeError', function (done) { 165 | expect(function () { 166 | new RendererState({ 167 | id: 'TEST-ID', 168 | root: {}, 169 | window: {}, 170 | width: 100, 171 | height: 100, 172 | renderEvents: [], 173 | scales: [], 174 | animEventsByTimeMs: [], 175 | }); 176 | }).to.throw(TypeError); 177 | 178 | done(); 179 | }); 180 | }); 181 | 182 | describe('animEventsByTimeMs param is missing', function() { 183 | it('should throw a TypeError', function (done) { 184 | expect(function () { 185 | new RendererState({ 186 | id: 'TEST-ID', 187 | root: {}, 188 | window: { document: {} }, 189 | width: 100, 190 | height: 100, 191 | renderEvents: [], 192 | scales: [], 193 | }); 194 | }).to.throw(TypeError); 195 | 196 | done(); 197 | }); 198 | }); 199 | 200 | describe('defaulted params instantiation', function() { 201 | beforeEach(function(done) { 202 | rendererState = new RendererState({ 203 | id: 'TEST-ID', 204 | window: { document: {} }, 205 | root: {}, 206 | animEventsByTimeMs: [], 207 | }); 208 | 209 | done(); 210 | }); 211 | 212 | afterEach(function(done) { 213 | rendererState = null; 214 | done(); 215 | }); 216 | 217 | it('should have a width of zero', function(done) { 218 | expect(rendererState.width).to.equal(0); 219 | done(); 220 | }); 221 | 222 | it('should have a height of zero', function(done) { 223 | expect(rendererState.height).to.equal(0); 224 | done(); 225 | }); 226 | 227 | it('should have no renderEvents', function(done) { 228 | expect(rendererState.renderEvents).to.have.length(0); 229 | done(); 230 | }); 231 | 232 | it('should have no scales', function(done) { 233 | expect(rendererState.scales).to.have.length(0); 234 | done(); 235 | }); 236 | }); 237 | 238 | describe('full params instantiation', function() { 239 | beforeEach(function(done) { 240 | rendererState = new RendererState({ 241 | id: 'TEST-ID', 242 | window: { document: {} }, 243 | root: {}, 244 | width: 100, 245 | height: 100, 246 | renderEvents: ['not empty'], 247 | scales: ['not empty'], 248 | animEventsByTimeMs: [], 249 | }); 250 | 251 | done(); 252 | }); 253 | 254 | afterEach(function(done) { 255 | rendererState = null; 256 | done(); 257 | }); 258 | 259 | it('should have a document', function(done) { 260 | expect(rendererState.document).not.to.be.undefined; 261 | done(); 262 | }); 263 | 264 | it('should have a root', function(done) { 265 | expect(rendererState.root).not.to.be.undefined; 266 | done(); 267 | }); 268 | 269 | it('should have a width', function(done) { 270 | expect(rendererState.width).to.equal(100); 271 | done(); 272 | }); 273 | 274 | it('should have a height', function(done) { 275 | expect(rendererState.height).to.equal(100); 276 | done(); 277 | }); 278 | 279 | it('should have renderEvents', function(done) { 280 | expect(rendererState.renderEvents).to.have.length(1); 281 | done(); 282 | }); 283 | 284 | it('should have scales', function(done) { 285 | expect(rendererState.scales).to.have.length(1); 286 | done(); 287 | }); 288 | }); 289 | }); 290 | 291 | describe('D3RendererState', function () { 292 | let rendererState; 293 | 294 | describe('missing svg param instantiation', function () { 295 | 296 | it('should throw a TypeError', function (done) { 297 | expect(function () { 298 | new D3RendererState({ 299 | id: 'TEST-ID', 300 | window: { document: {} }, 301 | root: {}, 302 | width: 100, 303 | height: 100, 304 | renderEvents: ['not empty'], 305 | scales: ['not empty'], 306 | animEventsByTimeMs: [], 307 | }); 308 | }).to.throw(TypeError); 309 | 310 | done(); 311 | }); 312 | }); 313 | 314 | describe('full params instantiation', function () { 315 | 316 | beforeEach(function (done) { 317 | rendererState = new D3RendererState({ 318 | id: 'TEST-ID', 319 | window: { document: {} }, 320 | root: {}, 321 | svg: 'TEST-SVG', 322 | animEventsByTimeMs: [], 323 | d3: () => {}, 324 | }); 325 | 326 | done(); 327 | }); 328 | 329 | it('should be a RendererState', function (done) { 330 | expect(rendererState).to.be.instanceof(RendererState); 331 | done(); 332 | }); 333 | 334 | it('should have an svg property', function (done) { 335 | expect(rendererState.svg).to.equal('TEST-SVG'); 336 | done(); 337 | }); 338 | 339 | it('should throw an error if no params', function (done) { 340 | expect(function () { new D3RendererState(); }).to.throw(TypeError); 341 | done(); 342 | }); 343 | 344 | it('should throw an error if empty params', function (done) { 345 | expect(function () { new D3RendererState({}); }).to.throw(TypeError); 346 | done(); 347 | }); 348 | }); 349 | }); 350 | 351 | describe('ThreeJsRendererState', function () { 352 | let rendererState, params; 353 | 354 | beforeEach(function (done) { 355 | params = { 356 | id: 'TEST-ID', 357 | window: { document: {} }, 358 | root: {}, 359 | camera: 'TEST-CAMERA', 360 | scene: 'TEST-SCENE', 361 | renderer: 'TEST-RENDERER', 362 | THREE: 'TEST-THREE', 363 | animEventsByTimeMs: [], 364 | }; 365 | 366 | rendererState = new ThreeJsRendererState(params); 367 | 368 | done(); 369 | }); 370 | 371 | it('should be a RendererState', function (done) { 372 | expect(rendererState).to.be.instanceof(RendererState); 373 | done(); 374 | }); 375 | 376 | it('should have a camera property', function (done) { 377 | expect(rendererState.camera).to.equal('TEST-CAMERA'); 378 | done(); 379 | }); 380 | 381 | it('should have a scene property', function (done) { 382 | expect(rendererState.scene).to.equal('TEST-SCENE'); 383 | done(); 384 | }); 385 | 386 | it('should have a renderer property', function (done) { 387 | expect(rendererState.renderer).to.equal('TEST-RENDERER'); 388 | done(); 389 | }); 390 | 391 | it('should throw an error if no params', function (done) { 392 | expect(function () { new ThreeJsRendererState(); }).to.throw(TypeError); 393 | done(); 394 | }); 395 | 396 | it('should throw an error if empty params', function (done) { 397 | expect(function () { new ThreeJsRendererState({}); }).to.throw(TypeError); 398 | done(); 399 | }); 400 | 401 | it('should throw an error if no camera', function (done) { 402 | delete params.camera; 403 | expect(function () { new ThreeJsRendererState(params); }).to.throw(TypeError); 404 | done(); 405 | }); 406 | 407 | it('should throw an error if no scene', function (done) { 408 | delete params.scene; 409 | expect(function () { new ThreeJsRendererState(params); }).to.throw(TypeError); 410 | done(); 411 | }); 412 | 413 | it('should throw an error if no renderer', function (done) { 414 | delete params.renderer; 415 | expect(function () { new ThreeJsRendererState(params); }).to.throw(TypeError); 416 | done(); 417 | }); 418 | 419 | it('should throw an error if no THREE', function (done) { 420 | delete params.THREE; 421 | expect(function () { new ThreeJsRendererState(params); }).to.throw(TypeError); 422 | done(); 423 | }); 424 | }); 425 | 426 | describe('AnimEvent', function() { 427 | let animEvent; 428 | 429 | describe('no params instantiation', function() { 430 | it('should throw if we do not have any params', function (done) { 431 | expect(function () { new AnimEvent(); }).to.throw(TypeError); 432 | done(); 433 | }); 434 | }); 435 | 436 | describe('empty params instantiation', function() { 437 | it('should throw if we do not have any params', function (done) { 438 | expect(function () { new AnimEvent({}); }).to.throw(TypeError); 439 | done(); 440 | }); 441 | }); 442 | 443 | describe('minimal params instantiation', function() { 444 | let params; 445 | 446 | beforeEach(function(done) { 447 | params = { event: { note: 127 }}; 448 | animEvent = new AnimEvent(params); 449 | done(); 450 | }); 451 | 452 | afterEach(function(done) { 453 | params = animEvent = null; 454 | done(); 455 | }); 456 | 457 | it('should have the event we passed in', function(done) { 458 | expect(animEvent.event).to.equal(params.event); 459 | done(); 460 | }); 461 | 462 | it('should default the track to 0', function(done) { 463 | expect(animEvent.track).to.equal(0); 464 | done(); 465 | }); 466 | 467 | it('should default the length in microseconds to 0', function(done) { 468 | expect(animEvent.lengthMicroSec).to.equal(0); 469 | done(); 470 | }); 471 | 472 | it('should generate an id from track and note', function(done) { 473 | expect(animEvent.id).to.equal('0-127'); 474 | done(); 475 | }); 476 | 477 | it('should set the startTimeMicroSec to zero', function (done) { 478 | expect(animEvent.startTimeMicroSec).to.equal(0); 479 | done(); 480 | }); 481 | 482 | describe('and track information instantiation', function() { 483 | beforeEach(function(done) { 484 | params.track = 7; 485 | animEvent = new AnimEvent(params); 486 | done(); 487 | }); 488 | 489 | it('should have the track we set', function(done) { 490 | expect(animEvent.track).to.equal(params.track); 491 | done(); 492 | }); 493 | 494 | it('should default the length in microseconds to 0', function(done) { 495 | expect(animEvent.lengthMicroSec).to.equal(0); 496 | done(); 497 | }); 498 | 499 | it('should generate an id from track and note', function(done) { 500 | expect(animEvent.id).to.equal('7-127'); 501 | done(); 502 | }); 503 | 504 | describe('and length in microseconds information instantiation', function() { 505 | beforeEach(function(done) { 506 | params.lengthMicroSec = 100; 507 | animEvent = new AnimEvent(params); 508 | done(); 509 | }); 510 | 511 | it('should have the length in microseconds we set', function(done) { 512 | expect(animEvent.lengthMicroSec).to.equal(params.lengthMicroSec); 513 | done(); 514 | }); 515 | 516 | describe('and custom id instantiation', function() { 517 | beforeEach(function(done) { 518 | params.id = 'CUSTOM'; 519 | animEvent = new AnimEvent(params); 520 | done(); 521 | }); 522 | 523 | it('should have id we set', function(done) { 524 | expect(animEvent.id).to.equal(params.id); 525 | done(); 526 | }); 527 | }); 528 | }); 529 | }); 530 | }); 531 | }); 532 | 533 | describe('RenderEvent', function () { 534 | 535 | describe('no params instantiation', function () { 536 | 537 | it('should throw an error', function (done) { 538 | expect(function () { new RenderEvent(); }).to.throw(TypeError); 539 | done(); 540 | }); 541 | }); 542 | 543 | describe('empty params instantiation', function () { 544 | 545 | it('should throw an error', function (done) { 546 | expect(function () { new RenderEvent({}); }).to.throw(TypeError); 547 | done(); 548 | }); 549 | }); 550 | 551 | describe('missing required params', function () { 552 | let params; 553 | 554 | beforeEach(function (done) { 555 | params = {}; 556 | done(); 557 | }); 558 | 559 | afterEach(function (done) { 560 | params = null; 561 | done(); 562 | }); 563 | 564 | 565 | describe('only pasing in an id', function () { 566 | 567 | beforeEach(function (done) { 568 | params.id = 'TEST-ID'; 569 | done(); 570 | }); 571 | 572 | it('should throw error', function (done) { 573 | expect(function () { new RenderEvent(params); }).to.throw(TypeError); 574 | done(); 575 | }); 576 | 577 | describe('and a track', function () { 578 | 579 | beforeEach(function (done) { 580 | params.track = 1; 581 | done(); 582 | }); 583 | 584 | it('should throw error', function (done) { 585 | expect(function () { new RenderEvent(params); }).to.throw(TypeError); 586 | done(); 587 | }); 588 | 589 | describe('and a subtype', function () { 590 | 591 | beforeEach(function (done) { 592 | params.subtype = 'TEST-SUBTYPE'; 593 | done(); 594 | }); 595 | 596 | it('should throw error', function (done) { 597 | expect(function () { new RenderEvent(params); }).to.throw(TypeError); 598 | done(); 599 | }); 600 | 601 | describe('and an x', function () { 602 | 603 | beforeEach(function (done) { 604 | params.x = 'TEST-X'; 605 | done(); 606 | }); 607 | 608 | it('should throw error', function (done) { 609 | expect(function () { new RenderEvent(params); }).to.throw(TypeError); 610 | done(); 611 | }); 612 | 613 | describe('and an y', function () { 614 | 615 | beforeEach(function (done) { 616 | params.y = 'TEST-Y'; 617 | done(); 618 | }); 619 | 620 | it('should throw error', function (done) { 621 | expect(function () { new RenderEvent(params); }).to.throw(TypeError); 622 | done(); 623 | }); 624 | 625 | describe('and an length in microseconds', function () { 626 | 627 | beforeEach(function (done) { 628 | params.lengthMicroSec = 'TEST-LENGTH'; 629 | done(); 630 | }); 631 | 632 | it('should throw an error', function (done) { 633 | expect(function () { new RenderEvent(params); }).to.throw(TypeError); 634 | done(); 635 | }); 636 | 637 | describe('and a start time in microseconds', function () { 638 | 639 | beforeEach(function (done) { 640 | params.startTimeMicroSec = 'TEST-START'; 641 | done(); 642 | }); 643 | 644 | it('should throw error', function (done) { 645 | expect(function () { new RenderEvent(params); }).to.throw(TypeError); 646 | done(); 647 | }); 648 | 649 | describe('and an event', function () { 650 | 651 | beforeEach(function (done) { 652 | params.event = 'TEST-EVENT'; 653 | done(); 654 | }); 655 | 656 | it('should not throw error', function (done) { 657 | expect(function () { new RenderEvent(params); }).not.to.throw(TypeError); 658 | done(); 659 | }); 660 | }); 661 | }); 662 | }); 663 | }); 664 | }); 665 | }); 666 | }); 667 | }); 668 | }); 669 | 670 | describe('minimal params instantiation', function () { 671 | let renderEvent; 672 | 673 | beforeEach(function (done) { 674 | renderEvent = new RenderEvent({ 675 | id: 'TEST-ID', 676 | track: 1, 677 | event: 'TEST-EVENT', 678 | subtype: 'TEST-SUBTYPE', 679 | x: 0, 680 | y: 0, 681 | lengthMicroSec: 0, 682 | startTimeMicroSec: 0 683 | }); 684 | 685 | done(); 686 | }); 687 | 688 | it('should have defaulted z to zero', function (done) { 689 | expect(renderEvent.z).to.equal(0); 690 | done(); 691 | }); 692 | 693 | it('should have defaulted color to white', function (done) { 694 | expect(renderEvent.color).to.equal('#FFFFFF'); 695 | done(); 696 | }); 697 | }); 698 | }); 699 | 700 | describe('D3RenderEvent', function () { 701 | 702 | it('should throw an error with no params', function (done) { 703 | expect(function () { new D3RenderEvent(); }).to.throw(TypeError); 704 | done(); 705 | }); 706 | 707 | it('should throw an error with empty params', function (done) { 708 | expect(function () { new D3RenderEvent({}); }).to.throw(TypeError); 709 | done(); 710 | }); 711 | 712 | describe('with a scale, path and radius', function () { 713 | let params; 714 | 715 | beforeEach(function (done) { 716 | params = { 717 | id: 'TEST-ID', 718 | track: 1, 719 | event: 'TEST-EVENT', 720 | subtype: 'TEST-SUBTYPE', 721 | x: 0, 722 | y: 0, 723 | lengthMicroSec: 0, 724 | startTimeMicroSec: 0, 725 | path: 'TEST-PATH', 726 | scales: ['TEST-SCALE'] 727 | }; 728 | done(); 729 | }); 730 | 731 | it('should throw an error (since it cannot have a path and radius)', function (done) { 732 | expect(function () { new D3RenderEvent(params); }).to.throw(TypeError); 733 | done(); 734 | }); 735 | 736 | }); 737 | 738 | describe('with a scale, but no path or radius', function () { 739 | let params; 740 | 741 | beforeEach(function (done) { 742 | params = { 743 | id: 'TEST-ID', 744 | track: 1, 745 | event: 'TEST-EVENT', 746 | subtype: 'TEST-SUBTYPE', 747 | x: 0, 748 | y: 0, 749 | lengthMicroSec: 0, 750 | startTimeMicroSec: 0, 751 | scales: ['TEST-SCALE'] 752 | }; 753 | done(); 754 | }); 755 | 756 | it('should throw an error', function (done) { 757 | expect(function () { new D3RenderEvent(params); }).to.throw(TypeError); 758 | done(); 759 | }); 760 | 761 | }); 762 | 763 | describe('with a path, no radius and no scale', function () { 764 | let params; 765 | 766 | beforeEach(function (done) { 767 | params = { 768 | id: 'TEST-ID', 769 | track: 1, 770 | event: 'TEST-EVENT', 771 | subtype: 'TEST-SUBTYPE', 772 | x: 0, 773 | y: 0, 774 | lengthMicroSec: 0, 775 | startTimeMicroSec: 0, 776 | path: 'TEST-PATH' 777 | }; 778 | done(); 779 | }); 780 | 781 | it('should throw an error', function (done) { 782 | expect(function () { new D3RenderEvent(params); }).to.throw(TypeError); 783 | done(); 784 | }); 785 | }); 786 | 787 | describe('without line, path, or circle', function () { 788 | let params; 789 | 790 | beforeEach(function (done) { 791 | params = { 792 | id: 'TEST-ID', 793 | track: 1, 794 | event: 'TEST-EVENT', 795 | subtype: 'TEST-SUBTYPE', 796 | x: 0, 797 | y: 0, 798 | lengthMicroSec: 0, 799 | startTimeMicroSec: 0, 800 | }; 801 | 802 | done(); 803 | }); 804 | 805 | it('should throw an error', function (done) { 806 | expect(function () { new D3RenderEvent(params); }).to.throw(TypeError); 807 | done(); 808 | }); 809 | }); 810 | 811 | describe('with a path and radius', function () { 812 | let params; 813 | 814 | beforeEach(function (done) { 815 | params = { 816 | id: 'TEST-ID', 817 | track: 1, 818 | event: 'TEST-EVENT', 819 | subtype: 'TEST-SUBTYPE', 820 | x: 0, 821 | y: 0, 822 | lengthMicroSec: 0, 823 | startTimeMicroSec: 0, 824 | path: 'TEST-PATH', 825 | radius: 3.14 826 | }; 827 | done(); 828 | }); 829 | 830 | it('should throw an error', function (done) { 831 | expect(function () { new D3RenderEvent(params); }).to.throw(TypeError); 832 | done(); 833 | }); 834 | }); 835 | 836 | describe('with a path and a scale', function () { 837 | let params; 838 | 839 | beforeEach(function (done) { 840 | params = { 841 | id: 'TEST-ID', 842 | track: 1, 843 | event: 'TEST-EVENT', 844 | subtype: 'TEST-SUBTYPE', 845 | x: 0, 846 | y: 0, 847 | lengthMicroSec: 0, 848 | startTimeMicroSec: 0, 849 | path: 'TEST-PATH', 850 | scale: 'TEST-LENGTH' 851 | }; 852 | 853 | done(); 854 | }); 855 | 856 | it('should not throw error', function (done) { 857 | expect(function () { new D3RenderEvent(params); }).not.to.throw(TypeError); 858 | done(); 859 | }); 860 | }); 861 | }); 862 | 863 | describe('ThreeJsRenderEvent', function () { 864 | 865 | it('should throw an error with no params', function (done) { 866 | expect(function () { new ThreeJsRenderEvent(); }).to.throw(TypeError); 867 | done(); 868 | }); 869 | 870 | it('should throw an error with empty params', function (done) { 871 | expect(function () { new ThreeJsRenderEvent({}); }).to.throw(TypeError); 872 | done(); 873 | }); 874 | 875 | describe('with params', function () { 876 | let params; 877 | 878 | beforeEach(function (done) { 879 | params = { 880 | id: 'TEST-ID', 881 | track: 1, 882 | event: 'TEST-EVENT', 883 | type: 'note', 884 | subtype: 'on', 885 | x: 0, 886 | y: 0, 887 | z: 0, 888 | zRot: 10, 889 | lengthMicroSec: 0, 890 | startTimeMicroSec: 0 891 | }; 892 | done(); 893 | }); 894 | 895 | it('should not throw an error when all expected params passed in', function (done) { 896 | expect(function () { new ThreeJsRenderEvent(params); }).not.to.throw(TypeError); 897 | done(); 898 | }); 899 | 900 | it('should default rotation to zero when it is left out', function (done) { 901 | delete params.zRot; 902 | expect((new ThreeJsRenderEvent(params)).zRot).to.equal(0); 903 | done(); 904 | }); 905 | 906 | it('should throw an error z is left out', function (done) { 907 | delete params.z; 908 | expect(function () { new ThreeJsRenderEvent(params); }).to.throw(TypeError); 909 | done(); 910 | }); 911 | }); 912 | }); 913 | }); 914 | -------------------------------------------------------------------------------- /test/fixtures/MIDIOkFormat1.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edhille/midi-visualizer/c23c1bd8d0983cba9fe95c0f85f3a790cb44da8b/test/fixtures/MIDIOkFormat1.mid -------------------------------------------------------------------------------- /test/fixtures/minimal-valid-midi.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edhille/midi-visualizer/c23c1bd8d0983cba9fe95c0f85f3a790cb44da8b/test/fixtures/minimal-valid-midi.mid -------------------------------------------------------------------------------- /test/helpers.js: -------------------------------------------------------------------------------- 1 | /* jshint expr: true */ 2 | 'use strict'; 3 | 4 | const sinon = require('sinon'); 5 | 6 | function createMockMidi() { 7 | return { 8 | header: { 9 | timeDivision: 96 10 | }, 11 | tracks: [ 12 | { 13 | events: [ 14 | { type: 'note', subtype: 'on', note: 1 }, 15 | { type: 'note', subtype: 'off', note: 1 }, 16 | { type: 'note', subtype: 'on', note: 10 }, 17 | { type: 'note', subtype: 'off', note: 10 }, 18 | { type: 'note', subtype: 'on', note: 5 }, 19 | { type: 'note', subtype: 'off', note: 5 } 20 | ] 21 | }, 22 | { 23 | events: [] 24 | }, 25 | { 26 | events: [ 27 | { type: 'note', subtype: 'on', note: 20 }, 28 | { type: 'note', subtype: 'off', note: 20 }, 29 | { type: 'note', subtype: 'on', note: 10 }, 30 | { type: 'note', subtype: 'off', note: 10 }, 31 | { type: 'note', subtype: 'on', note: 30 }, 32 | { type: 'note', subtype: 'off', note: 30 } 33 | ] 34 | } 35 | ] 36 | }; 37 | } 38 | 39 | function createMockDoc() { 40 | return { 41 | appendChild: sinon.spy(), 42 | getElementsByClassName: sinon.stub() 43 | }; 44 | } 45 | 46 | module.exports = { 47 | createMockMidi: createMockMidi, 48 | createMockDoc: createMockDoc 49 | }; 50 | -------------------------------------------------------------------------------- /test/midi-transformer.spec.js: -------------------------------------------------------------------------------- 1 | /* jshint expr: true */ 2 | /* globals describe: true, beforeEach: true, afterEach: true, it: true */ 3 | 'use strict'; 4 | 5 | const chai = require('chai'); 6 | const expect = chai.expect; 7 | const sinon = require('sinon'); 8 | 9 | const _ = require('lodash'); 10 | 11 | const midiParser = require('func-midi-parser'); 12 | const MidiNoteOnEvent = midiParser.types.MidiNoteOnEvent; 13 | const MidiNoteOffEvent = midiParser.types.MidiNoteOffEvent; 14 | const MidiMetaTempoEvent = midiParser.types.MidiMetaTempoEvent; 15 | const MidiMetaInstrumentNameEvent = midiParser.types.MidiMetaInstrumentNameEvent; 16 | 17 | const { transformMidi } = require('../src/midi-transformer'); 18 | 19 | function generateMidiData() { 20 | return { 21 | header: { 22 | timeDivision: 1000 23 | }, 24 | tracks: [{ 25 | events: [new MidiMetaTempoEvent({ 26 | delta: 0, 27 | microsecPerQn: 10000 28 | }), new MidiNoteOnEvent({ 29 | note: 1, 30 | delta: 0 31 | }), new MidiNoteOffEvent({ 32 | note: 1, 33 | delta: 1000 34 | }), new MidiNoteOnEvent({ 35 | note: 3, 36 | delta: 0 37 | }), new MidiNoteOffEvent({ 38 | note: 3, 39 | delta: 2000 40 | })] 41 | }, { 42 | events: [new MidiMetaInstrumentNameEvent({ 43 | delta: 0, 44 | dataBytes: [102, 111, 111] 45 | }), new MidiNoteOffEvent({ // though this note will be in the results, it's time gets accounted for 46 | note: 6, 47 | delta: 2000 48 | }), new MidiNoteOnEvent({ 49 | note: 2, 50 | delta: 1000 51 | }), new MidiNoteOffEvent({ 52 | note: 2, 53 | delta: 2000 54 | })] 55 | }] 56 | }; 57 | } 58 | 59 | describe('midi-transformer', function() { 60 | 61 | let animEventsByTimeMs, consoleSpy; 62 | 63 | beforeEach(function(done) { 64 | const midiData = generateMidiData(); 65 | 66 | consoleSpy = sinon.stub(console, 'error'); 67 | 68 | animEventsByTimeMs = transformMidi(midiData); 69 | 70 | done(); 71 | }); 72 | 73 | afterEach(function(done) { 74 | animEventsByTimeMs = null; 75 | if (consoleSpy) consoleSpy.restore(); 76 | consoleSpy = null; 77 | done(); 78 | }); 79 | 80 | it('should have converted midi data into animEvents by time, adding in 1/32 note timer events', function(done) { 81 | expect(Object.keys(animEventsByTimeMs)).to.eql(_.range(0, 51).map(function (num) { return num.toString(); })); 82 | done(); 83 | }); 84 | 85 | it('should have three events for the second two slots that have notes (10 and 30)', function(done) { 86 | expect(animEventsByTimeMs[10]).to.have.length(3); 87 | expect(animEventsByTimeMs[30]).to.have.length(3); 88 | done(); 89 | }); 90 | 91 | it('should have calculated length in microseconds of each note', function(done) { 92 | expect(animEventsByTimeMs[0].filter(e => e.event.subtype === 'on')[0].lengthMicroSec).to.equal(10000); 93 | expect(animEventsByTimeMs[10].filter(e => e.event.subtype === 'on')[0].lengthMicroSec).to.equal(20000); 94 | expect(animEventsByTimeMs[30].filter(e => e.event.subtype === 'on')[0].lengthMicroSec).to.equal(20000); 95 | done(); 96 | }); 97 | 98 | it('should log an error for seeing an end note with no begginging, but still parse', function(done) { 99 | consoleSpy.calledWithMatch(/no active note/); 100 | done(); 101 | }); 102 | }); 103 | 104 | -------------------------------------------------------------------------------- /test/midi-visualizer.spec.js: -------------------------------------------------------------------------------- 1 | /* jshint expr: true */ 2 | /* globals describe: true, beforeEach: true, afterEach: true, it: true */ 3 | 'use strict'; 4 | 5 | const rewire = require('rewire'); 6 | const chai = require('chai'); 7 | const expect = chai.expect; 8 | const sinon = require('sinon'); 9 | 10 | const midiVisualizer = rewire('../src/midi-visualizer'); 11 | 12 | function stubAudioLoader(loadDataStub) { 13 | const stub = sinon.stub(); 14 | 15 | stub.returns({ 16 | loadData: loadDataStub 17 | }); 18 | 19 | return stub; 20 | } 21 | 22 | function stubAudioPlayer() { 23 | return sinon.stub({ 24 | play: function() {}, 25 | pause: function() {}, 26 | getPlayheadTime: function() {} 27 | }); 28 | } 29 | 30 | function stubLoadData(audioPlayerStub) { 31 | const stub = sinon.stub(); 32 | 33 | stub.callsArgWith(1, null, audioPlayerStub); 34 | 35 | return stub; 36 | } 37 | 38 | function stubMidiParser() { 39 | return sinon.stub({ 40 | parse: function() {} 41 | }); 42 | } 43 | 44 | function stubRenderer() { 45 | const stub = sinon.stub({ 46 | play: function() { }, 47 | pause: function() { }, 48 | stop: function() { }, 49 | restart: function () { }, 50 | resize: function () { } 51 | }); 52 | 53 | stub.play.returns(stub); 54 | stub.pause.returns(stub); 55 | stub.stop.returns(stub); 56 | stub.restart.returns(stub); 57 | stub.resize.returns(stub); 58 | 59 | return stub; 60 | } 61 | 62 | describe('midi-visualizer', function() { 63 | 64 | describe('with valid instantaion', function() { 65 | let testVisualizer, config, setupError; 66 | let audioLoaderStub, audioPlayerStub, midiParserStub, rendererStub, loadDataStub; 67 | 68 | beforeEach(function(done) { 69 | setupError = null; 70 | midiParserStub = stubMidiParser(); 71 | audioPlayerStub = stubAudioPlayer(); 72 | 73 | rendererStub = stubRenderer(); 74 | loadDataStub = stubLoadData(audioPlayerStub); 75 | 76 | audioLoaderStub = stubAudioLoader(loadDataStub); 77 | 78 | midiVisualizer.__set__('AudioPlayer', audioLoaderStub); 79 | midiVisualizer.__set__('midiParser', midiParserStub); 80 | 81 | config = { 82 | audio: { 83 | data: new Uint8Array(10) 84 | }, 85 | midi: { 86 | data: new Uint8Array(10) 87 | }, 88 | window: {}, 89 | renderer: function () { return rendererStub; }, 90 | raf: sinon.spy() 91 | }; 92 | 93 | midiVisualizer(config).then(function(visualizer) { 94 | testVisualizer = visualizer; 95 | }).catch(function (err) { 96 | setupError = err; 97 | }).then(function () { 98 | done(); 99 | }); 100 | }); 101 | 102 | afterEach(function(done) { 103 | audioLoaderStub = midiParserStub = config = testVisualizer = setupError = null; 104 | done(); 105 | }); 106 | 107 | it('should not have given an error to the callback', function(done) { 108 | expect(setupError).to.be.null; 109 | done(); 110 | }); 111 | 112 | it('should have called midiPlayer.parse', function(done) { 113 | expect(midiParserStub.parse.called).to.be.true; 114 | done(); 115 | }); 116 | 117 | it('should have called audioPlayer.loadData', function(done) { 118 | expect(loadDataStub.called).to.be.true; 119 | done(); 120 | }); 121 | 122 | it('should have handed the callback a visualizer', function(done) { 123 | expect(testVisualizer).not.to.be.null; 124 | done(); 125 | }); 126 | 127 | describe('#play', function() { 128 | let state; 129 | 130 | beforeEach(function(done) { 131 | testVisualizer = testVisualizer.play(); 132 | state = testVisualizer.value(); 133 | 134 | done(); 135 | }); 136 | 137 | afterEach(function(done) { 138 | state = null; 139 | done(); 140 | }); 141 | 142 | it('should set the state to playing', function(done) { 143 | expect(state.isPlaying).to.be.true; 144 | done(); 145 | }); 146 | 147 | it('should start the audioPlayer', function(done) { 148 | expect(audioPlayerStub.play.called).to.be.true; 149 | 150 | done(); 151 | }); 152 | 153 | describe('#pause', function() { 154 | let state; 155 | 156 | beforeEach(function(done) { 157 | testVisualizer = testVisualizer.pause(); 158 | state = testVisualizer.value(); 159 | 160 | done(); 161 | }); 162 | 163 | it('should set the state to not playing', function(done) { 164 | expect(state.isPlaying).to.be.false; 165 | 166 | done(); 167 | }); 168 | 169 | it('should pause the audioPlayer', function(done) { 170 | expect(audioPlayerStub.pause.called).to.be.true; 171 | 172 | done(); 173 | }); 174 | }); 175 | 176 | describe('#stop', function() { 177 | let state; 178 | 179 | beforeEach(function(done) { 180 | testVisualizer = testVisualizer.stop(); 181 | state = testVisualizer.value(); 182 | 183 | done(); 184 | }); 185 | 186 | it('should set the state to not playing', function(done) { 187 | expect(state.isPlaying).to.be.false; 188 | 189 | done(); 190 | }); 191 | 192 | it('should pause the audioPlayer', function(done) { 193 | expect(audioPlayerStub.pause.called).to.be.true; 194 | 195 | done(); 196 | }); 197 | 198 | describe('#restart', function () { 199 | let state; 200 | 201 | beforeEach(function(done) { 202 | testVisualizer = testVisualizer.restart(); 203 | state = testVisualizer.value(); 204 | 205 | done(); 206 | }); 207 | 208 | it('should set the state to playing', function(done) { 209 | expect(state.isPlaying).to.be.true; 210 | done(); 211 | }); 212 | 213 | it('should start the audioPlayer', function(done) { 214 | expect(audioPlayerStub.play.called).to.be.true; 215 | 216 | done(); 217 | }); 218 | 219 | }); 220 | }); 221 | }); 222 | 223 | describe('#resize', function () { 224 | let state; 225 | 226 | beforeEach(function(done) { 227 | testVisualizer = testVisualizer.resize({ width: 100, height: 200 }); 228 | state = testVisualizer.value(); 229 | 230 | done(); 231 | }); 232 | 233 | it('should call renderer resize', function (done) { 234 | expect(state.renderer.resize.called).to.be.true; 235 | done(); 236 | }); 237 | }); 238 | }); 239 | 240 | describe('with invalid instantiation', function () { 241 | let testVisualizer, config, setupError; 242 | let audioLoaderStub, audioPlayerStub, midiParserStub, rendererStub, loadDataStub; 243 | 244 | beforeEach(function(done) { 245 | midiParserStub = stubMidiParser(); 246 | audioPlayerStub = stubAudioPlayer(); 247 | 248 | rendererStub = stubRenderer(); 249 | loadDataStub = stubLoadData(audioPlayerStub); 250 | 251 | audioLoaderStub = stubAudioLoader(loadDataStub); 252 | 253 | midiVisualizer.__set__('AudioPlayer', audioLoaderStub); 254 | midiVisualizer.__set__('midiParser', midiParserStub); 255 | 256 | config = { 257 | audio: { 258 | data: new Uint8Array(10) 259 | }, 260 | midi: { 261 | data: new Uint8Array(10) 262 | }, 263 | renderer: rendererStub 264 | }; 265 | 266 | done(); 267 | }); 268 | 269 | afterEach(function(done) { 270 | audioLoaderStub = midiParserStub = config = testVisualizer = setupError = null; 271 | done(); 272 | }); 273 | 274 | describe('when audio loader passes error to callback', function () { 275 | 276 | beforeEach(function (done) { 277 | loadDataStub.callsArgWith(1, 'no data'); 278 | 279 | midiVisualizer(config).then(function(visualizer) { 280 | testVisualizer = visualizer; 281 | }).catch(function (err) { 282 | setupError = err; 283 | }).then(function () { 284 | done(); 285 | }); 286 | }); 287 | 288 | it('should pass error to callback', function (done) { 289 | expect(setupError).not.to.be.null; 290 | done(); 291 | }); 292 | }); 293 | 294 | describe('when midi parser throws an error', function () { 295 | 296 | beforeEach(function (done) { 297 | midiParserStub.parse.throws(new TypeError('no data')); 298 | 299 | midiVisualizer(config).then(function (visualizer) { 300 | testVisualizer = visualizer; 301 | }).catch(function (err) { 302 | setupError = err; 303 | }).then(function () { 304 | done(); 305 | }); 306 | }); 307 | 308 | it('should pass error to callback', function (done) { 309 | expect(setupError).not.to.be.null; 310 | done(); 311 | }); 312 | }); 313 | 314 | describe('when no renderer', function () { 315 | 316 | beforeEach(function (done) { 317 | delete config.renderer; 318 | 319 | midiVisualizer(config).then(function(visualizer) { 320 | testVisualizer = visualizer; 321 | }).catch(function (err) { 322 | setupError = err; 323 | }).then(function () { 324 | done(); 325 | }); 326 | }); 327 | 328 | it('should pass error to callback', function (done) { 329 | expect(setupError).not.to.be.null; 330 | done(); 331 | }); 332 | }); 333 | 334 | describe('when renderer does not implement #prep', function () { 335 | 336 | beforeEach(function (done) { 337 | delete rendererStub.prep; 338 | 339 | midiVisualizer(config).then(function(visualizer) { 340 | testVisualizer = visualizer; 341 | }).catch(function (err) { 342 | setupError = err; 343 | }).then(function () { 344 | done(); 345 | }); 346 | }); 347 | 348 | it('should pass error to callback', function (done) { 349 | expect(setupError).not.to.be.null; 350 | done(); 351 | }); 352 | }); 353 | }); 354 | }); 355 | 356 | -------------------------------------------------------------------------------- /test/renderers/d3.spec.js: -------------------------------------------------------------------------------- 1 | /* jshint expr: true */ 2 | /* globals describe: true, beforeEach: true, afterEach: true, it: true */ 3 | 'use strict'; 4 | 5 | var rewire = require('rewire'); 6 | var chai = require('chai'); 7 | var expect = chai.expect; 8 | var sinon = require('sinon'); 9 | 10 | var testHelpers = require('../helpers'); 11 | 12 | var dataTypes = require('../../src/data-types'); 13 | var D3RenderEvent = dataTypes.D3RenderEvent; 14 | var D3RendererState = dataTypes.D3RendererState; 15 | var d3Renderer = rewire('../../src/renderers/d3'); 16 | 17 | function createMockSvg() { 18 | // wow...chaining makes mocking really hard... 19 | var svgStub = sinon.stub({ 20 | selectAll: function () {}, 21 | select: function () {}, 22 | classed: function () {}, 23 | attr: function () {}, 24 | append: function () {}, 25 | data: function () {} 26 | }); 27 | var dataStub = sinon.stub(); 28 | var enterStub = sinon.stub(); 29 | var appendStub = sinon.stub(); 30 | var exitStub = sinon.stub(); 31 | var transitionStub = sinon.stub(); 32 | var durationStub = sinon.stub(); 33 | var attrStub = sinon.stub(); 34 | var removeStub = sinon.stub(); 35 | var eachStub = sinon.stub(); 36 | 37 | svgStub.select.returns(svgStub); 38 | svgStub.append.returns(svgStub); 39 | svgStub.selectAll.returns(svgStub); 40 | svgStub.data.returns({ 41 | enter: enterStub, 42 | exit: exitStub, 43 | remove: removeStub 44 | }); 45 | enterStub.returns({ append: appendStub }); 46 | 47 | appendStub.returns({ 48 | attr: sinon.spy(), 49 | each: eachStub 50 | }); 51 | exitStub.returns({ 52 | transition: transitionStub 53 | }); 54 | transitionStub.returns({ 55 | duration: durationStub 56 | }); 57 | durationStub.returns({ 58 | attr: attrStub 59 | }); 60 | attrStub.returns({ 61 | remove: sinon.spy() 62 | }); 63 | 64 | return { 65 | svgMock: svgStub, 66 | dataMock: dataStub, 67 | removeMock: removeStub, 68 | eachMock: eachStub 69 | }; 70 | } 71 | 72 | function createMockD3() { 73 | var d3Stub = sinon.stub({ 74 | scale: { 75 | linear: function() {} 76 | }, 77 | select: function () {} 78 | }); 79 | 80 | d3Stub.scale = { 81 | linear: sinon.stub() 82 | }; 83 | 84 | var appendStub = sinon.stub(); 85 | 86 | appendStub.returns(createMockSvg().svgMock); 87 | 88 | d3Stub.select.returns({ 89 | append: appendStub, 90 | empty: function () { return true; } 91 | }); 92 | 93 | var rangeStub = sinon.stub(); 94 | var domainStub = sinon.stub(); 95 | 96 | rangeStub.returns({ 97 | domain: domainStub 98 | }); 99 | 100 | d3Stub.scale.linear.returns({ 101 | range: rangeStub, 102 | domain: domainStub 103 | }); 104 | 105 | return { 106 | d3: d3Stub, 107 | mockRange: rangeStub, 108 | mockDomain: domainStub 109 | }; 110 | } 111 | 112 | describe('renderers.d3', function () { 113 | 114 | describe('#prepDOM', function () { 115 | var prepDOM, mockMidi, mockConfig, mockD3, mockDomain, mockRange, state, testWidth, testHeight; 116 | 117 | beforeEach(function (done) { 118 | prepDOM = d3Renderer.prepDOM; 119 | done(); 120 | }); 121 | 122 | describe('minimal working behavior', function () { 123 | 124 | beforeEach(function (done) { 125 | var d3Mocks = createMockD3(); 126 | mockD3 = d3Mocks.d3; 127 | mockDomain = d3Mocks.mockDomain; 128 | mockRange = d3Mocks.mockRange; 129 | mockMidi = testHelpers.createMockMidi(); 130 | testWidth = 999; 131 | testHeight = 666; 132 | mockConfig = { 133 | id: 'TEST-D3', 134 | window: { 135 | document: { 136 | documentElement: {} 137 | } 138 | }, 139 | root: {}, 140 | width: testWidth, 141 | height: testHeight 142 | }; 143 | d3Renderer.__with__({ 144 | d3: mockD3 145 | })(function () { 146 | state = prepDOM(mockMidi, mockConfig); 147 | done(); 148 | }); 149 | }); 150 | 151 | afterEach(function (done) { 152 | mockDomain.reset(); 153 | mockRange.reset(); 154 | prepDOM = mockMidi = mockConfig = mockD3 = mockDomain = mockRange = state = testWidth = testHeight = null; 155 | done(); 156 | }); 157 | 158 | it('should have set scales in the state with our min/max note', function (done) { 159 | var trackOneWidthScale = state.scales[0].x.domain.args[0][0]; 160 | expect(trackOneWidthScale[0]).to.equal(1); 161 | expect(trackOneWidthScale[1]).to.equal(10); 162 | done(); 163 | }); 164 | 165 | it('should have not set scales for second track (with no events to base scale from)', function (done) { 166 | expect(state.scales[1]);// .to.have.length(0); 167 | done(); 168 | }); 169 | }); 170 | 171 | describe('error cases', function () { 172 | 173 | it('should throw an error trying to access the document if window is not passed in the config', function (done) { 174 | expect(function () { 175 | prepDOM(null, {}); 176 | }).to.throw(/document/); 177 | 178 | done(); 179 | }); 180 | 181 | it('should throw an error if width cannot be determined', function (done) { 182 | expect(function () { 183 | prepDOM(null, { 184 | window: { 185 | innerWidth: null, 186 | document: { 187 | documentElement: {} 188 | } 189 | } 190 | }); 191 | }).to.throw(/width/); 192 | 193 | done(); 194 | }); 195 | 196 | it('should throw an error if height cannot be determined', function (done) { 197 | expect(function () { 198 | prepDOM(null, { 199 | window: { 200 | innerWidth: 100, 201 | innerHeight: null, 202 | document: { 203 | documentElement: {} 204 | } 205 | } 206 | }); 207 | }).to.throw(/height/); 208 | 209 | done(); 210 | }); 211 | }); 212 | }); 213 | 214 | describe('#generate', function () { 215 | var renderer; 216 | var generateConfig, renderConfig; 217 | var mockMidi, mockD3, mockState; 218 | var rafSpy, rafStub, nowStub, domPrepStub, mockSvg, mockSvgEach; 219 | 220 | beforeEach(function (done) { 221 | var svgMocks = createMockSvg(); 222 | rafSpy = sinon.spy(); 223 | rafStub = sinon.stub(); 224 | nowStub = sinon.stub(); 225 | domPrepStub = sinon.stub(); 226 | mockMidi = testHelpers.createMockMidi(); 227 | mockD3 = createMockD3(); 228 | mockSvg = svgMocks.svgMock; 229 | mockSvgEach = svgMocks.eachMock; 230 | mockState = new D3RendererState({ 231 | id: 'TEST-D3', 232 | window: { 233 | document: {}, 234 | performance: { 235 | now: nowStub 236 | }, 237 | requestAnimationFrame: rafStub 238 | }, 239 | root: testHelpers.createMockDoc(), 240 | raf: rafSpy, 241 | svg: mockSvg 242 | }); 243 | 244 | domPrepStub.returns(mockState); 245 | 246 | generateConfig = { 247 | prepDOM: domPrepStub, 248 | mapEvents: sinon.spy(), 249 | frameRenderer: sinon.spy(), 250 | resize: sinon.spy(), 251 | transformers: [sinon.spy(), null, sinon.spy()] 252 | }; 253 | 254 | renderConfig = { 255 | window: { 256 | document: { 257 | documentElement: {} 258 | } 259 | }, 260 | root: testHelpers.createMockDoc(), 261 | raf: sinon.spy(), 262 | width: 999, 263 | height: 666 264 | }; 265 | 266 | d3Renderer.__with__({ 267 | d3: mockD3 268 | })(function () { 269 | renderer = d3Renderer.generate(generateConfig)(mockMidi, renderConfig); 270 | done(); 271 | }); 272 | }); 273 | 274 | it('should have called our genrateConfig.prepDOM', function (done) { 275 | expect(domPrepStub.called).to.be.true; 276 | done(); 277 | }); 278 | 279 | it('should have called our generateConfig.mapEvents', function (done) { 280 | expect(generateConfig.mapEvents.called).to.be.true; 281 | done(); 282 | }); 283 | 284 | describe('api', function () { 285 | 286 | describe('#resize', function () { 287 | 288 | it('should throw an error if we call resize', function (done) { 289 | expect(function () { 290 | d3Renderer.resize(); 291 | }).to.throw(/Implement/); 292 | 293 | done(); 294 | }); 295 | }); 296 | 297 | describe('#play', function () { 298 | var utilsPlaySpy, utilsRenderSpy; 299 | 300 | beforeEach(function (done) { 301 | utilsPlaySpy = sinon.stub(); 302 | utilsRenderSpy = sinon.stub(); 303 | 304 | // set it to call the _render callback it is provided with empty data 305 | utilsPlaySpy.callsArgWith(2, mockState, [], []); 306 | 307 | // have the renderer call the "rafFn" 308 | var renderEvent = new D3RenderEvent({ 309 | id: 'TEST-ONE', 310 | track: 1, 311 | subtype: 'on', 312 | startTimeMicroSec: 0, 313 | lengthMicroSec: 1, 314 | x: 0, 315 | y: 0, 316 | radius: 1, 317 | color: 'blue' 318 | }); 319 | utilsRenderSpy.callsArgWith(2, mockState, [renderEvent], []); 320 | 321 | done(); 322 | }); 323 | 324 | it('should have a #play method', function (done) { 325 | expect(renderer).to.respondTo('play'); 326 | done(); 327 | }); 328 | 329 | describe('when no playhead position is supplied', function () { 330 | 331 | it('should call renderUtils.play with no playheadTime', function (done) { 332 | d3Renderer.__with__({ 333 | renderUtils: { 334 | play: utilsPlaySpy, 335 | render: sinon.spy() 336 | } 337 | })(function () { 338 | renderer.play(null); // TODO: should we pass audioPlayer? 339 | 340 | var utilsPlaySecondArg = utilsPlaySpy.firstCall.args[1]; 341 | 342 | expect(utilsPlaySecondArg).to.be.null; 343 | 344 | done(); 345 | }); 346 | }); 347 | }); 348 | 349 | describe('when given an explicit playhead position', function () { 350 | 351 | it('should only schedule timers for events happening on or after playhead position', function (done) { 352 | var TEST_PLAYHEAD_TIME = 100; 353 | 354 | d3Renderer.__with__({ 355 | renderUtils: { 356 | play: utilsPlaySpy, 357 | render: sinon.spy() 358 | } 359 | })(function () { 360 | renderer.play(TEST_PLAYHEAD_TIME); 361 | 362 | expect(utilsPlaySpy.args[0][1]).to.be.equal(TEST_PLAYHEAD_TIME); 363 | 364 | done(); 365 | }); 366 | }); 367 | }); 368 | 369 | describe('internal rafFn', function () { 370 | var getBBoxSpy, setAttrSpy; 371 | 372 | beforeEach(function (done) { 373 | getBBoxSpy = sinon.stub(); 374 | setAttrSpy = sinon.spy(); 375 | 376 | getBBoxSpy.returns({ width: 1, height: 1 }); 377 | 378 | mockSvgEach.yieldsOn({ 379 | tagName: 'path', 380 | getBBox: getBBoxSpy, 381 | setAttribute: setAttrSpy 382 | }, {}); 383 | 384 | d3Renderer.__with__({ 385 | renderUtils: { 386 | play: utilsPlaySpy, 387 | render: utilsRenderSpy 388 | } 389 | })(function () { 390 | renderer.play(); 391 | 392 | done(); 393 | }); 394 | }); 395 | 396 | it('should have been passed the given state', function (done) { 397 | expect(utilsRenderSpy.firstCall.args[0]).to.equal(mockState); 398 | done(); 399 | }); 400 | 401 | it('should have set a transform', function (done) { 402 | expect(setAttrSpy.firstCall.args[1]).to.match(/matrix/); 403 | done(); 404 | }); 405 | }); 406 | 407 | it('should have a #pause method', function (done) { 408 | expect(renderer).to.respondTo('pause'); 409 | done(); 410 | }); 411 | }); 412 | 413 | describe('#restart', function () { 414 | var utilsPlaySpy; 415 | 416 | beforeEach(function (done) { 417 | utilsPlaySpy = sinon.stub(); 418 | 419 | // set it to call the _render callback it is provided with empty data 420 | utilsPlaySpy.callsArgWith(2, mockState, [], []); 421 | 422 | mockState.root.getElementsByClassName.returns([{ 423 | style: { display: 'none' }, 424 | getAttribute: sinon.stub() 425 | }]); 426 | 427 | d3Renderer.__with__({ 428 | renderUtils: { 429 | play: utilsPlaySpy, 430 | render: sinon.spy() 431 | } 432 | })(function () { 433 | renderer.restart(null); // TODO: should we pass audioPlayer? 434 | 435 | done(); 436 | }); 437 | }); 438 | 439 | it('should call play', function (done) { 440 | expect(utilsPlaySpy.called).to.be.true; 441 | done(); 442 | }); 443 | }); 444 | }); 445 | 446 | describe('error cases', function () { 447 | 448 | describe('when no ???', function () { 449 | 450 | beforeEach(function (done) { 451 | done(); 452 | }); 453 | }); 454 | }); 455 | }); 456 | }); 457 | -------------------------------------------------------------------------------- /test/renderers/three.spec.js: -------------------------------------------------------------------------------- 1 | /* jshint expr: true */ 2 | /* globals describe: true, before: true, beforeEach: true, afterEach: true, it: true */ 3 | 'use strict'; 4 | 5 | var rewire = require('rewire'); 6 | var chai = require('chai'); 7 | var expect = chai.expect; 8 | var sinon = require('sinon'); 9 | 10 | var testHelpers = require('../helpers'); 11 | 12 | var dataTypes = require('../../src/data-types'); 13 | var ThreeJsRendererState = dataTypes.ThreeJsRendererState; 14 | var threeJsRenderer = rewire('../../src/renderers/three'); 15 | var TEST_NOTE_MIN = 1; 16 | var TEST_NOTE_MAX = 10; 17 | 18 | function createThreeJsMock() { 19 | var sceneStub = sinon.stub(); 20 | var cameraStub = sinon.stub(); 21 | var rendererStub = sinon.stub(); 22 | // TODO: remove when done debugging actual implementation 23 | var axisStub = sinon.stub(); 24 | var spotLightStub = sinon.stub(); 25 | var ambientLightStub = sinon.stub(); 26 | 27 | rendererStub.returns({ 28 | setSize: sinon.spy(), 29 | domElement: { 30 | getAttribute: sinon.spy(), 31 | setAttribute: sinon.spy() 32 | } 33 | }); 34 | 35 | cameraStub.returns({ 36 | lookAt: sinon.spy(), 37 | position: { x: 0, y: 0, z: 0 } 38 | }); 39 | 40 | sceneStub.returns({ 41 | add: sinon.spy(), 42 | getObjectByName: sinon.stub(), 43 | remove: sinon.spy() 44 | }); 45 | 46 | // TODO: remove when done debugging actual implementation 47 | spotLightStub.returns({ 48 | position: sinon.stub({ set: function() {} }), 49 | castShadow: false, 50 | target: null 51 | }); 52 | 53 | return { 54 | AxisHelper: axisStub, // TODO: remove when done debugging actual implementation 55 | SpotLight: spotLightStub, // TODO: remove when done debugging actual implementation 56 | AmbientLight: ambientLightStub, // TODO: remove when done debugging actual implementation 57 | Scene: sceneStub, 58 | PerspectiveCamera: cameraStub, 59 | WebGLRenderer: rendererStub 60 | }; 61 | } 62 | 63 | describe('renderers.threejs', function () { 64 | 65 | describe('#prepDOM', function () { 66 | var prepDOM, threeMock, mockMidi, TEST_WIDTH, TEST_HEIGHT; 67 | 68 | beforeEach(function (done) { 69 | prepDOM = threeJsRenderer.prepDOM; 70 | threeMock = createThreeJsMock(); 71 | mockMidi = testHelpers.createMockMidi(); 72 | TEST_WIDTH = 100; 73 | TEST_HEIGHT = 150; 74 | done(); 75 | }); 76 | 77 | afterEach(function (done) { 78 | threeMock = null; 79 | mockMidi = null; 80 | done(); 81 | }); 82 | 83 | describe('minimal working behavior', function () { 84 | var mockRenderer, state; 85 | 86 | beforeEach(function (done) { 87 | mockRenderer = threeMock.WebGLRenderer(); 88 | 89 | threeJsRenderer.__with__({ 90 | THREE: threeMock 91 | })(function () { 92 | state = prepDOM(mockMidi, { 93 | window: { 94 | innerWidth: TEST_WIDTH, 95 | innerHeight: TEST_HEIGHT, 96 | document: { 97 | documentElement: {} 98 | } 99 | }, 100 | root: { 101 | appendChild: sinon.spy(), 102 | getElementsByClassName: sinon.stub() 103 | } 104 | }); 105 | 106 | done(); 107 | }); 108 | }); 109 | 110 | it('should set the renderer size based on given dimensions', function (done) { 111 | expect(mockRenderer.setSize.alwaysCalledWith(TEST_WIDTH, TEST_HEIGHT)).to.be.true; 112 | done(); 113 | }); 114 | 115 | it('should have set scales in the state with our min/max note', function (done) { 116 | var trackOneWidthScale = state.scales[0].x.domain(); 117 | expect(trackOneWidthScale[0]).to.equal(TEST_NOTE_MIN); 118 | expect(trackOneWidthScale[1]).to.equal(TEST_NOTE_MAX); 119 | done(); 120 | }); 121 | 122 | it('should have not set scales for second track (with no events to base scale from)', function (done) { 123 | expect(state.scales[1]); 124 | done(); 125 | }); 126 | }); 127 | 128 | describe('error cases', function () { 129 | 130 | it('should throw an error trying to access the document if window is not passed in the config', function (done) { 131 | expect(function () { 132 | prepDOM(null, {}); 133 | }).to.throw(/document/); 134 | 135 | done(); 136 | }); 137 | 138 | it('should throw an error if width cannot be determined', function (done) { 139 | expect(function () { 140 | prepDOM(null, { 141 | window: { 142 | innerWidth: null, 143 | document: { 144 | documentElement: {} 145 | } 146 | } 147 | }); 148 | }).to.throw(/width/); 149 | 150 | done(); 151 | }); 152 | 153 | it('should throw an error if height cannot be determined', function (done) { 154 | expect(function () { 155 | prepDOM(null, { 156 | window: { 157 | innerWidth: 100, 158 | innerHeight: null, 159 | document: { 160 | documentElement: {} 161 | } 162 | } 163 | }); 164 | }).to.throw(/height/); 165 | 166 | done(); 167 | }); 168 | }); 169 | }); 170 | 171 | describe('#generate', function () { 172 | var renderer; 173 | var generateConfig, renderConfig; 174 | var mockMidi, mockThreeJs, mockState; 175 | var rafSpy, rafStub, nowStub, sceneStub, domPrepStub; 176 | 177 | beforeEach(function (done) { 178 | rafSpy = sinon.spy(); 179 | rafStub = sinon.stub(); 180 | nowStub = sinon.stub(); 181 | domPrepStub = sinon.stub(); 182 | sceneStub = sinon.stub({ 183 | add: function () {}, 184 | remove: function () {}, 185 | getObjectByName: function () {} 186 | }); 187 | 188 | mockMidi = testHelpers.createMockMidi(); 189 | mockThreeJs = createThreeJsMock(); 190 | mockState = new ThreeJsRendererState({ 191 | id: 'TEST-ID', 192 | window: { 193 | document: {}, 194 | performance: { 195 | now: nowStub 196 | }, 197 | requestAnimationFrame: rafStub 198 | }, 199 | root: testHelpers.createMockDoc(), 200 | raf: rafSpy, 201 | camera: {}, 202 | scene: sceneStub, 203 | renderer: { 204 | render: sinon.spy() 205 | } 206 | }); 207 | 208 | domPrepStub.returns(mockState); 209 | 210 | generateConfig = { 211 | prepDOM: domPrepStub, 212 | mapEvents: sinon.spy(), 213 | frameRenderFn: sinon.spy(), 214 | resize: sinon.spy(), 215 | transformers: [sinon.spy(), null, sinon.spy()] 216 | }; 217 | 218 | renderConfig = { 219 | window: { 220 | document: { 221 | documentElement: {} 222 | } 223 | }, 224 | root: testHelpers.createMockDoc(), 225 | raf: sinon.spy(), 226 | width: 999, 227 | height: 666 228 | }; 229 | 230 | threeJsRenderer.__with__({ 231 | THREE: mockThreeJs 232 | })(function () { 233 | renderer = threeJsRenderer.generate(generateConfig)(mockMidi, renderConfig); 234 | done(); 235 | }); 236 | }); 237 | 238 | it('should have called our genrateConfig.prepDOM', function (done) { 239 | expect(domPrepStub.called).to.be.true; 240 | done(); 241 | }); 242 | 243 | it('should have called our generateConfig.mapEvents', function (done) { 244 | expect(generateConfig.mapEvents.called).to.be.true; 245 | done(); 246 | }); 247 | 248 | describe('api', function () { 249 | 250 | describe('#play', function () { 251 | var utilsPlaySpy; 252 | 253 | beforeEach(function (done) { 254 | utilsPlaySpy = sinon.stub(); 255 | 256 | // set it to call the _render callback it is provided with empty data 257 | utilsPlaySpy.callsArgOnWith(2, null, [], []); 258 | 259 | done(); 260 | }); 261 | 262 | it('should have a #play method', function (done) { 263 | expect(renderer).to.respondTo('play'); 264 | done(); 265 | }); 266 | 267 | describe('when no playhead position is supplied', function () { 268 | 269 | it('should call renderUtils.play with no playheadTime', function (done) { 270 | threeJsRenderer.__with__({ 271 | renderUtils: { 272 | play: utilsPlaySpy, 273 | render: sinon.spy() 274 | } 275 | })(function () { 276 | renderer.play(null); 277 | 278 | var utilsPlaySecondArg = utilsPlaySpy.firstCall.args[1]; 279 | 280 | expect(utilsPlaySecondArg).to.be.null; 281 | 282 | done(); 283 | }); 284 | }); 285 | }); 286 | 287 | describe('when given an explicit playhead position', function () { 288 | 289 | it('should only schedule timers for events happening on or after playhead position', function (done) { 290 | threeJsRenderer.__with__({ 291 | renderUtils: { 292 | play: utilsPlaySpy, 293 | render: sinon.spy() 294 | } 295 | })(function () { 296 | renderer.play(100); 297 | 298 | expect(utilsPlaySpy.args[0][1]).to.be.equal(100); 299 | 300 | done(); 301 | }); 302 | }); 303 | }); 304 | }); 305 | 306 | describe('#pause', function () { 307 | 308 | it('should have a #pause method', function (done) { 309 | expect(renderer).to.respondTo('pause'); 310 | done(); 311 | }); 312 | 313 | describe('when not currently playing', function () { 314 | 315 | it('should do nothing'); 316 | }); 317 | 318 | describe('when is currently playing', function () { 319 | 320 | it('should clear all timers'); 321 | }); 322 | }); 323 | 324 | describe('#restart', function () { 325 | var utilsPlaySpy; 326 | 327 | beforeEach(function (done) { 328 | utilsPlaySpy = sinon.stub(); 329 | 330 | // set it to call the _render callback it is provided with empty data 331 | utilsPlaySpy.callsArgWith(2, mockState, [], []); 332 | 333 | mockState.root.getElementsByClassName.returns([{ 334 | style: { display: 'none' }, 335 | getAttribute: sinon.stub() 336 | }]); 337 | 338 | threeJsRenderer.__with__({ 339 | renderUtils: { 340 | play: utilsPlaySpy, 341 | render: sinon.spy() 342 | } 343 | })(function () { 344 | renderer.restart(null); // TODO: should we pass audioPlayer? 345 | 346 | done(); 347 | }); 348 | }); 349 | 350 | it('should call play', function (done) { 351 | expect(utilsPlaySpy.called).to.be.true; 352 | done(); 353 | }); 354 | }); 355 | }); 356 | 357 | describe('error cases', function () { 358 | 359 | describe('when no ???', function () { 360 | 361 | beforeEach(function (done) { 362 | done(); 363 | }); 364 | }); 365 | }); 366 | }); 367 | 368 | describe('#resize', function () { 369 | var resize; 370 | 371 | beforeEach(function (done) { 372 | resize = threeJsRenderer.resize; 373 | 374 | // TODO: remove this when we actually test... 375 | resize(); 376 | 377 | done(); 378 | }); 379 | 380 | it('should do some sort of resizing'); 381 | }); 382 | 383 | describe('#cleanup', function () { 384 | var cleanup, mockState; 385 | 386 | before(function (done) { 387 | var mockThree = createThreeJsMock(); 388 | 389 | mockState = { 390 | scene: mockThree.Scene() 391 | }; 392 | 393 | cleanup = threeJsRenderer.cleanup; 394 | 395 | done(); 396 | }); 397 | 398 | afterEach(function (done) { 399 | mockState.scene.getObjectByName.reset(); 400 | 401 | done(); 402 | }); 403 | 404 | it('should do nothing if there are no events to clean up', function (done) { 405 | 406 | cleanup(mockState, [], []); 407 | 408 | expect(mockState.scene.getObjectByName.called).to.be.false; 409 | 410 | done(); 411 | }); 412 | 413 | it('should log an error to console if it cannot find the object to remove', function (done) { 414 | var consoleSpy = sinon.spy(); 415 | 416 | threeJsRenderer.__with__({ 417 | console: { 418 | error: consoleSpy 419 | } 420 | })(function () { 421 | cleanup(mockState, [], [{ id: 'NOT THERE' }]); 422 | 423 | expect(consoleSpy.args).to.match(/NO OBJ/); 424 | 425 | done(); 426 | }); 427 | }); 428 | 429 | it('should look to scene for any events passed in', function (done) { 430 | var TEST_ID = 'MATCH_ME'; 431 | 432 | mockState.scene.getObjectByName.returns({}); 433 | 434 | cleanup(mockState, [], [{ id: TEST_ID }]); 435 | 436 | expect(mockState.scene.getObjectByName.calledWith(TEST_ID)).to.be.true; 437 | expect(mockState.scene.remove.called).to.be.true; 438 | 439 | done(); 440 | }); 441 | }); 442 | }); 443 | 444 | -------------------------------------------------------------------------------- /test/renderers/utils.spec.js: -------------------------------------------------------------------------------- 1 | /* jshint expr: true */ 2 | /* globals describe: true, beforeEach: true, afterEach: true, it: true */ 3 | 'use strict'; 4 | 5 | var rewire = require('rewire'); 6 | var chai = require('chai'); 7 | var expect = chai.expect; 8 | var sinon = require('sinon'); 9 | 10 | var RenderEvent = require('../../src/data-types').RenderEvent; 11 | var RendererState = require('../../src/data-types').RendererState; 12 | 13 | var renderUtils = rewire('../../src/renderers/utils'); 14 | 15 | describe('renderer.utils', function () { 16 | 17 | describe('#play', function () { 18 | var play; 19 | var testState, rendererState, renderFnSpy, audioPlayerMock, playheadStub; 20 | 21 | beforeEach(function (done) { 22 | play = renderUtils.play; 23 | 24 | renderFnSpy = sinon.spy(); 25 | 26 | var rafStub = sinon.stub(); 27 | rafStub.onFirstCall().callsArgAsync(0); 28 | 29 | rendererState = new RendererState({ 30 | id: 'TEST-ID', 31 | window: { 32 | document: {}, 33 | requestAnimationFrame: rafStub, 34 | cancelAnimationFrame: sinon.spy() 35 | }, 36 | root: 'TEST-ROOT', 37 | raf: sinon.spy(), 38 | renderEvents: { 39 | 0: [], 40 | 100: [ 41 | new RenderEvent({ 42 | id: 'TEST-ID', 43 | track: 1, 44 | subtype: 'on', 45 | x: 0, 46 | y: 0, 47 | lengthMicroSec: 1, 48 | startTimeMicroSec: 1 49 | }) 50 | ], 51 | 200: [] 52 | } 53 | }); 54 | 55 | playheadStub = sinon.stub(); 56 | 57 | done(); 58 | }); 59 | 60 | afterEach(function (done) { 61 | renderUtils.stop(testState); // since play is a closure, we have to "stop" it 62 | play = testState = rendererState = renderFnSpy = audioPlayerMock = playheadStub = null; 63 | done(); 64 | }); 65 | 66 | describe('initial call', function () { 67 | 68 | beforeEach(function (done) { 69 | playheadStub.onFirstCall().returns(100); 70 | playheadStub.onSecondCall().returns(1001); 71 | 72 | audioPlayerMock = { 73 | lengthMs: 1000, 74 | isPlaying: true, 75 | getPlayheadTime: playheadStub }; 76 | 77 | testState = play(rendererState, audioPlayerMock, renderFnSpy); 78 | 79 | done(); 80 | }); 81 | 82 | it('should have renderEvents, by time', function (done) { 83 | expect(testState.renderEvents).to.have.keys(['0', '100', '200']); 84 | done(); 85 | }); 86 | 87 | it('should have scales', function (done) { 88 | // TOOD: or not??? 89 | expect(testState.scales).to.have.length(0); 90 | done(); 91 | }); 92 | 93 | it('should have called renderFn with state and render events', function (done) { 94 | setTimeout(function () { 95 | expect(renderFnSpy.called).to.be.true; 96 | // expect(renderFnSpy.calledWith(rendererState, [], rendererState.renderEvents[100], 300)).to.be.true; 97 | done(); 98 | }, 0); 99 | }); 100 | }); 101 | 102 | describe('when audioPlayer is not playing', function () { 103 | 104 | beforeEach(function(done) { 105 | playheadStub.reset(); 106 | 107 | audioPlayerMock = { 108 | lengthMs: 1000, 109 | isPlaying: false, 110 | getPlayheadTime: playheadStub }; 111 | 112 | testState = play(rendererState, audioPlayerMock, renderFnSpy); 113 | 114 | done(); 115 | }); 116 | 117 | it('should not try to get playhead time', function(done) { 118 | expect(playheadStub.called).to.be.false; 119 | done(); 120 | }); 121 | }); 122 | 123 | describe('when audioPlayer is past the end of the song', function () { 124 | 125 | beforeEach(function(done) { 126 | playheadStub.reset(); 127 | 128 | audioPlayerMock = { 129 | lengthMs: 1000, 130 | isPlaying: true, 131 | getPlayheadTime: playheadStub }; 132 | 133 | playheadStub.returns(audioPlayerMock.lengthMs + 1); 134 | 135 | testState = play(rendererState, audioPlayerMock, renderFnSpy); 136 | 137 | done(); 138 | }); 139 | 140 | it('should try to get playhead time', function(done) { 141 | setTimeout(function () { 142 | expect(playheadStub.called).to.be.true; 143 | done(); 144 | }, 0); 145 | }); 146 | 147 | it('should not have called renderFn', function (done) { 148 | setTimeout(function () { 149 | expect(renderFnSpy.called).to.be.false; 150 | done(); 151 | }, 0); 152 | }); 153 | }); 154 | 155 | describe('when audioPlayer has been rewound', function () { 156 | var resumeStub; 157 | 158 | beforeEach(function(done) { 159 | resumeStub = sinon.spy(); 160 | playheadStub.reset(); 161 | 162 | audioPlayerMock = { 163 | lengthMs: 1000, 164 | isPlaying: true, 165 | getPlayheadTime: playheadStub }; 166 | 167 | playheadStub.returns(-1); 168 | 169 | testState = play(rendererState, audioPlayerMock, renderFnSpy, resumeStub); 170 | 171 | done(); 172 | }); 173 | 174 | it('should try to get playhead time', function(done) { 175 | setTimeout(function () { 176 | expect(playheadStub.called).to.be.true; 177 | done(); 178 | }, 0); 179 | }); 180 | 181 | it('should have called resumeFn', function (done) { 182 | setTimeout(function () { 183 | expect(resumeStub.called).to.be.true; 184 | done(); 185 | }, 0); 186 | }); 187 | }); 188 | 189 | describe('#pause', function () { 190 | var pause = renderUtils.pause; 191 | 192 | beforeEach(function (done) { 193 | testState = pause(testState); 194 | done(); 195 | }); 196 | 197 | describe('#play (after pause)', function () { 198 | 199 | beforeEach(function (done) { 200 | testState = play(testState, null, renderFnSpy); 201 | done(); 202 | }); 203 | }); 204 | }); 205 | }); 206 | 207 | describe('#transformEvents', function () { 208 | var transformEvents; 209 | 210 | beforeEach(function (done) { 211 | transformEvents = renderUtils.transformEvents; 212 | done(); 213 | }); 214 | 215 | afterEach(function (done) { 216 | transformEvents = null; 217 | done(); 218 | }); 219 | 220 | it('should return no renderEvents if passed no animEvents', function (done) { 221 | expect(Object.keys(transformEvents(null, null, []))).to.have.length(0); 222 | done(); 223 | }); 224 | 225 | it('should log error to console if no transformer function', function (done) { 226 | var consoleStub = { 227 | error: sinon.spy() 228 | }; 229 | renderUtils.__with__({ 230 | console: consoleStub 231 | })(function () { 232 | transformEvents(null, [], [[{track: 0}]]); 233 | expect(consoleStub.error.lastCall.calledWithMatch(/No transform/)).to.be.true; 234 | done(); 235 | }); 236 | }); 237 | 238 | it('should pass events to the transform', function (done) { 239 | var transformSpy = sinon.spy(); 240 | var animEvent = { track: 0 }; 241 | var state = null; 242 | transformEvents(state, [transformSpy], [[animEvent]]); 243 | expect(transformSpy.lastCall.calledWith(state, animEvent)).to.be.true; 244 | done(); 245 | }); 246 | 247 | it('should return events with the same time-keys as those passed in'); 248 | }); 249 | 250 | describe('#mapEvents', function () { 251 | var mapEvents; 252 | 253 | beforeEach(function (done) { 254 | mapEvents = renderUtils.mapEvents; 255 | done(); 256 | }); 257 | 258 | it('should call transformMidi helper', function (done) { 259 | var transformSpy = sinon.stub(); 260 | transformSpy.returns([]); 261 | 262 | renderUtils.__with__({ 263 | transformMidi: transformSpy 264 | })(function () { 265 | var initialState = new RendererState({ 266 | id: 'TEST-ID', 267 | root: {}, 268 | window: { document: {} } 269 | }); 270 | var testConfig = { tranformers: [] }; 271 | var testMidi = []; 272 | var mockState = mapEvents(initialState, testMidi, testConfig); 273 | 274 | expect(transformSpy.calledWithExactly(testMidi)).to.be.true; 275 | expect(Object.keys(mockState.renderEvents)).to.have.length(0); 276 | 277 | done(); 278 | }); 279 | }); 280 | }); 281 | 282 | describe('#maxNote', function () { 283 | var maxNote; 284 | 285 | beforeEach(function (done) { 286 | maxNote = renderUtils.maxNote; 287 | done(); 288 | }); 289 | 290 | it('should return current note if it is highest', function (done) { 291 | expect(maxNote(127, { note: 0 })).to.equal(127); 292 | done(); 293 | }); 294 | 295 | it('should return new note if it is highest', function (done) { 296 | expect(maxNote(0, { note: 127 })).to.equal(127); 297 | done(); 298 | }); 299 | }); 300 | 301 | describe('#minNote', function () { 302 | var minNote; 303 | 304 | beforeEach(function (done) { 305 | minNote = renderUtils.minNote; 306 | done(); 307 | }); 308 | 309 | it('should return current note if it is lowest', function (done) { 310 | expect(minNote(0, { note: 127 })).to.equal(0); 311 | done(); 312 | }); 313 | 314 | it('should new note if it is lowest', function (done) { 315 | expect(minNote(127, { note: 0 })).to.equal(0); 316 | done(); 317 | }); 318 | }); 319 | 320 | describe('#isNoteOnEvent', function () { 321 | var isNoteOnEvent; 322 | 323 | beforeEach(function (done) { 324 | isNoteOnEvent = renderUtils.isNoteOnEvent; 325 | done(); 326 | }); 327 | 328 | it('should return true if note is an "on" note', function (done) { 329 | expect(isNoteOnEvent({ type: 'note', subtype: 'on' })).to.be.true; 330 | done(); 331 | }); 332 | 333 | it('should return false if note is an "off" event', function (done) { 334 | expect(isNoteOnEvent({ type: 'note', subtype: 'off' })).to.be.false; 335 | done(); 336 | }); 337 | 338 | it('should return false if event is not a note event', function (done) { 339 | expect(isNoteOnEvent({ type: 'meta' })).to.be.false; 340 | done(); 341 | }); 342 | }); 343 | 344 | describe('#scale', function () { 345 | // var scale; 346 | 347 | beforeEach(function (done) { 348 | // scale = renderUtils.scale; 349 | done(); 350 | }); 351 | 352 | it('should do what?'); 353 | }); 354 | 355 | describe('#render', function () { 356 | var cleanupSpy, rafSpy, rafStub, nowStub, mockState, renderFn; 357 | 358 | beforeEach(function (done) { 359 | renderFn = renderUtils.render; 360 | cleanupSpy = sinon.spy(); 361 | rafSpy = sinon.spy(); 362 | rafStub = sinon.stub(); 363 | nowStub = sinon.stub(); 364 | mockState = new RendererState({ 365 | id: 'TEST-ID', 366 | window: { 367 | document: {}, 368 | performance: { 369 | now: nowStub 370 | }, 371 | requestAnimationFrame: rafStub 372 | }, 373 | raf: sinon.spy(), 374 | root: {} 375 | }); 376 | done(); 377 | }); 378 | 379 | afterEach(function (done) { 380 | rafStub.reset(); 381 | nowStub.reset(); 382 | mockState = nowStub = rafStub = null; 383 | 384 | done(); 385 | }); 386 | 387 | describe('when there are no previous events and only "on" events to render', function () { 388 | 389 | beforeEach(function (done) { 390 | nowStub.returns(0); 391 | rafStub.callsArgWith(0, 14); 392 | var renderEvents = [ 393 | new RenderEvent({ 394 | id: 'TEST-TMER-ONE', 395 | track: 0, 396 | subtype: 'timer', 397 | lengthMicroSec: 1, 398 | startTimeMicroSec: 1, 399 | x: 0, 400 | y: 0, 401 | radius: 1, 402 | color: 'blue' 403 | }), 404 | new RenderEvent({ 405 | id: 'TEST-ONE', 406 | track: 1, 407 | subtype: 'on', 408 | lengthMicroSec: 1, 409 | startTimeMicroSec: 1, 410 | x: 0, 411 | y: 0, 412 | radius: 1, 413 | color: 'blue' 414 | }), 415 | new RenderEvent({ 416 | id: 'TEST-ONE', 417 | track: 1, 418 | subtype: 'on', 419 | lengthMicroSec: 1, 420 | startTimeMicroSec: 1, 421 | x: 0, 422 | y: 0, 423 | radius: 1, 424 | color: 'blue' 425 | }), 426 | new RenderEvent({ 427 | id: 'TEST-TWO', 428 | track: 2, 429 | subtype: 'on', 430 | lengthMicroSec: 1, 431 | startTimeMicroSec: 1, 432 | x: 1, 433 | y: 1, 434 | radius: 1, 435 | color: 'red' 436 | }) 437 | ]; 438 | mockState = renderFn(mockState, cleanupSpy, rafSpy, [], renderEvents); 439 | done(); 440 | }); 441 | 442 | it('should have called cleanup with no events to remove', function (done) { 443 | expect(cleanupSpy.firstCall.args[2]).to.have.length(0); 444 | done(); 445 | }); 446 | 447 | it('should have called raf with two events to add', function (done) { 448 | expect(rafSpy.firstCall.args[1]).to.have.length(3); 449 | done(); 450 | }); 451 | }); 452 | 453 | describe('when turning off only one event', function () { 454 | 455 | beforeEach(function (done) { 456 | nowStub.returns(0); 457 | rafStub.callsArgWith(0, 14); 458 | var offEvent = new RenderEvent({ 459 | id: 'TEST-ONE', 460 | subtype: 'on', 461 | track: 1, 462 | lengthMicroSec: 1, 463 | startTimeMicroSec: 1, 464 | x: 0, 465 | y: 0, 466 | radius: 1, 467 | color: 'blue' 468 | }); 469 | var runningEvents = [ 470 | offEvent, 471 | new RenderEvent({ 472 | id: 'TEST-TWO', 473 | track: 2, 474 | subtype: 'on', 475 | lengthMicroSec: 1, 476 | startTimeMicroSec: 1, 477 | x: 1, 478 | y: 1, 479 | radius: 1, 480 | color: 'red' 481 | }) 482 | ]; 483 | var renderEvents = [ 484 | offEvent.next({ subtype: 'off' }) 485 | ]; 486 | mockState = renderFn(mockState, cleanupSpy, rafSpy, runningEvents, renderEvents); 487 | done(); 488 | }); 489 | 490 | it('should have called cleanup with one event to remove', function (done) { 491 | expect(cleanupSpy.firstCall.args[1]).to.have.length(1); 492 | done(); 493 | }); 494 | }); 495 | 496 | describe('when an event that has an unknown subtype is passed in', function () { 497 | var consoleStub; 498 | 499 | beforeEach(function (done) { 500 | consoleStub = { 501 | error: sinon.spy() 502 | }; 503 | nowStub.returns(0); 504 | rafStub.callsArgWith(0, 14); 505 | var renderEvents = [ 506 | new RenderEvent({ 507 | id: 'TEST-ONE', 508 | track: 1, 509 | subtype: 'BAD', 510 | lengthMicroSec: 1, 511 | startTimeMicroSec: 1, 512 | x: 0, 513 | y: 0, 514 | radius: 1, 515 | color: 'blue' 516 | }) 517 | ]; 518 | renderUtils.__with__({ 519 | console: consoleStub 520 | })(function () { 521 | mockState = renderFn(mockState, cleanupSpy, rafSpy, [], renderEvents); 522 | done(); 523 | }); 524 | }); 525 | 526 | it('should log to console.error', function (done) { 527 | expect(consoleStub.error.calledWithMatch(/unknown render event subtype/)).to.be.true; 528 | done(); 529 | }); 530 | }); 531 | 532 | describe('when the time delta is 16ms', function () { 533 | var consoleStub; 534 | 535 | beforeEach(function (done) { 536 | consoleStub = { 537 | error: sinon.spy() 538 | }; 539 | nowStub.returns(0); 540 | rafStub.callsArgWith(0, 16); 541 | renderUtils.__with__({ 542 | console: consoleStub 543 | })(function () { 544 | mockState = renderFn(mockState, cleanupSpy, rafSpy, [], []); 545 | done(); 546 | }); 547 | }); 548 | 549 | it('should have called cleanup', function (done) { 550 | expect(cleanupSpy.called).to.be.true; 551 | done(); 552 | }); 553 | 554 | it('should not have called raf', function (done) { 555 | expect(rafSpy.called).to.be.false; 556 | done(); 557 | }); 558 | 559 | it('should log to console.error', function (done) { 560 | expect(consoleStub.error.calledWithMatch(/skipping render due to \d+ms delay/)).to.be.true; 561 | done(); 562 | }); 563 | }); 564 | }); 565 | }); 566 | --------------------------------------------------------------------------------