├── .gitignore ├── .vscode └── settings.json ├── FluidR3_GM └── percussion-ogg.js ├── README.md ├── Screenshot.png ├── blackKey.svg ├── css ├── Inputs.css ├── Interface.css ├── Settings.css ├── bootstrap-theme.min.css ├── bootstrap.min.css └── nano.min.css ├── eslintrc.json ├── fonts ├── glyphicons-halflings-regular.eot ├── glyphicons-halflings-regular.svg ├── glyphicons-halflings-regular.ttf ├── glyphicons-halflings-regular.woff └── glyphicons-halflings-regular.woff2 ├── images └── logo.svg ├── index.html ├── js ├── InputListeners.js ├── MicInputHandler.js ├── MidiInputHandler.js ├── MidiLoader.js ├── Rendering │ ├── BackgroundRender.js │ ├── DebugRender.js │ ├── InSongTextRenderer.js │ ├── MarkerRenderer.js │ ├── MeasureLinesRender.js │ ├── NoteParticleRender.js │ ├── NoteRender.js │ ├── OverlayRender.js │ ├── PianoParticleRender.js │ ├── PianoRender.js │ ├── ProgressBarRender.js │ ├── Render.js │ ├── RenderDimensions.js │ ├── RenderUtil.js │ ├── Sequencer.js │ └── SustainRenderer.js ├── Song.js ├── SoundfontLoader.js ├── Util.js ├── audio │ ├── AudioNote.js │ ├── AudioPlayer.js │ ├── Buffers.js │ └── GainNodeController.js ├── data │ ├── CONST.js │ └── exampleSongs.json ├── jquery-3.3.1.js ├── main.js ├── player │ ├── FileLoader.js │ ├── Player.js │ └── Tracks.js ├── settings │ ├── DefaultSettings.js │ ├── LocalStorageHandler.js │ └── Settings.js └── ui │ ├── DomHelper.js │ ├── ElementHighlight.js │ ├── Loader.js │ ├── Notification.js │ ├── SettingUI.js │ ├── SongUI.js │ ├── TrackUI.js │ ├── UI.js │ └── ZoomUI.js ├── lib ├── Base64.js ├── Base64binary.js ├── JASMID LICENSE.txt ├── Pickr │ ├── nano.css │ ├── pickr.es5.min.js │ └── pickr.es5.min.js.map ├── bootstrap.min.js └── jquery-3.3.1.slim.min.js ├── metronome ├── 1.wav └── 2.wav ├── mz_331_3.mid └── screenShotNew.png /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .eslintrc.json 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.autoClosingBrackets": "always", 3 | "editor.overviewRulerBorder": false 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Midiano 2 | 3 | ## A JavaScript MIDI-Player/ Piano-learning webapp 4 | 5 | ### [Try out here](https://midiano.com/) 6 | 7 | Midiano is a free Piano-learning webapp that runs on any device with a modern browser. 8 | Open any MIDI-File and Midiano shows you the notes as falling bars over a piano as well as the corresponding sheet music. 9 | Connect a MIDI-Keyboard to get instant feedback if you hit the correct notes. 10 | You can also use the keyboard as output device to play the MIDI-Files on your keyboard. 11 | 12 | 13 | 14 | 15 | ![Screenshot](/screenShotNew.png) 16 | 17 | 18 | I have continued development of Midiano in a private repository. This repository serves as a place for bug reports or feature requests. 19 | 20 | I will keep the (outdated) code in this repository public though, in case someone is interested in looking at or tinkering with it. However please note that it is not open source. 21 | 22 | The current version of the app can be accessed at [Midiano.com](https://midiano.com/). 23 | 24 | #### Browser Support : 25 | 26 | It runs on any browser (and device) that supports the WebAudioAPI (Full support apart from Internet Explorer). 27 | 28 | To connect a MIDI-Keyboard the browser also needs to support the WebMIDIAPI (Currently only Chrome and Edge). 29 | 30 | 31 | #### Features : 32 | 33 | - MIDI playback 34 | - MIDI-Keyboard support 35 | - Input - Let the song wait for you to hit the correct notes 36 | - Output - Use your MIDI-Keyboard as sound output 37 | - Automatic Sheet Music generation (Formatting & Rendering done with VexFlow) 38 | - Customize track colors, particle effects and track instruments 39 | - 3 different soundfonts from https://github.com/gleitz/midi-js-soundfonts 40 | 41 | #### Libraries used: 42 | 43 | - pickr - Color Picker - https://github.com/Simonwep/pickr 44 | - jQuery 45 | - Bootstrap (only really use the glyphicons) 46 | - VexFlow for Sheet formatting & rendering. 47 | -------------------------------------------------------------------------------- /Screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bewelge/MIDIano/c86cc1c9c39041a873aa8023589971f90cdc2ae1/Screenshot.png -------------------------------------------------------------------------------- /css/Inputs.css: -------------------------------------------------------------------------------- 1 | .sliderContainer { 2 | /* margin-top: 5px; 3 | margin-bottom: 5px; */ 4 | } 5 | .inputSelect { 6 | float: right; 7 | margin-left: 3px; 8 | } 9 | .inputSelectLabel, 10 | .sliderLabel, 11 | .checkboxlabel, 12 | .settingLabel { 13 | display: inline-block; 14 | color: var(--fontColor); 15 | z-index: 15; 16 | pointer-events: none; 17 | font-size: 0.75rem; 18 | -ms-user-select: none; /* Internet Explorer/Edge */ 19 | user-select: none; 20 | } 21 | 22 | .colorPickerButtonContainer { 23 | float: right; 24 | width: 1.5em; 25 | height: 1.5em; 26 | margin-top: 0em; 27 | border-radius: 0; 28 | border: 1px solid var(--borderColor); 29 | background-color: rgba(0, 0, 0, 0); 30 | } 31 | 32 | .checkboxlabel { 33 | color: var(--fontWeakenedColor); 34 | transition: all 0.15s ease-out; 35 | } 36 | .checkboxInput:checked + label { 37 | color: var(--fontColor); 38 | } 39 | 40 | .checkboxInput { 41 | float: right; 42 | width: 1.5em; 43 | height: 1.5em; 44 | margin-top: 0em; 45 | border-radius: 0; 46 | border: 1px solid var(--borderColor); 47 | margin: 0; 48 | 49 | background-color: none; 50 | box-shadow: inset 0em 0em 0em 1em var(--inputBgColor), 51 | inset 0em 0em 0em 0.75em var(--inputFontColor); 52 | appearance: none; 53 | -webkit-appearance: none; 54 | -moz-appearance: none; 55 | 56 | transition: all 0.15s ease-out; 57 | } 58 | 59 | .checkboxInput:checked { 60 | box-shadow: inset 0em 0em 0em 0.3em var(--inputBgColor), 61 | inset 0em 0em 0em 0.75em var(--inputFontColor); 62 | } 63 | .checkboxInput:active { 64 | border: 1px solid var(--borderColor); 65 | } 66 | .checkboxInput:focus { 67 | border: 1px solid var(--borderColor); 68 | outline: none; 69 | } 70 | 71 | input[type="text"] { 72 | outline: none; 73 | box-sizing: border-box; 74 | margin-left: 1px; 75 | margin-top: 1px; 76 | border-width: 0px; 77 | border-radius: 0em; 78 | margin-top: 3px; 79 | margin-bottom: 3px; 80 | padding-top: 6px; 81 | padding-bottom: 6px; 82 | background-color: var(--inputBgColor); 83 | color: var(--inputFontColor); 84 | } 85 | 86 | .sliderVal { 87 | margin-top: 5px; 88 | margin-right: 5px; 89 | float: right; 90 | color: var(--buttonFontColor); 91 | z-index: 15; 92 | pointer-events: none; 93 | font-size: 0.75rem; 94 | } 95 | input[type="range"] { 96 | -webkit-appearance: none; 97 | margin: 10px 0; 98 | width: 100%; 99 | padding-left: 1px; 100 | padding-right: 1px; 101 | transition: all 1s ease-out; 102 | background-color: rgba(0, 0, 0, 0); 103 | } 104 | input[type="range"]:focus { 105 | outline: none; 106 | } 107 | input[type="range"]::-webkit-slider-runnable-track { 108 | width: 100%; 109 | height: 16px; 110 | cursor: pointer; 111 | background: var(--inputBgColor); 112 | border-radius: 8px; 113 | border: 1px solid var(--borderColor); 114 | padding-left: 1px; 115 | padding-right: 1px; 116 | } 117 | input[type="range"]::-webkit-slider-thumb { 118 | border: 0px solid var(--borderColor); 119 | height: 12px; 120 | width: 12px; 121 | border-radius: 6px; 122 | background: var(--buttonFontColor); 123 | cursor: pointer; 124 | -webkit-appearance: none; 125 | margin-top: 1px; 126 | transition: all 0.2s ease-out; 127 | } 128 | input[type="range"]:focus::-webkit-slider-runnable-track { 129 | background: var(--inputBgColor); 130 | } 131 | input[type="range"]::-moz-range-track { 132 | width: 100%; 133 | height: 16px; 134 | cursor: pointer; 135 | background: var(--inputBgColor); 136 | border-radius: 8px; 137 | border: 1px solid var(--borderColor); 138 | padding-left: 2px; 139 | padding-right: 2px; 140 | } 141 | input[type="range"]::-moz-range-thumb { 142 | border: 0px solid var(--borderColor); 143 | height: 12px; 144 | width: 12px; 145 | border-radius: 6px; 146 | background: var(--buttonFontColor); 147 | cursor: pointer; 148 | margin-top: 1px; 149 | transition: all 1s ease-out; 150 | } 151 | input[type="range"]::-ms-track { 152 | width: 100%; 153 | height: 16px; 154 | cursor: pointer; 155 | background: transparent; 156 | border-color: transparent; 157 | color: transparent; 158 | padding-left: 1px; 159 | padding-right: 1px; 160 | } 161 | input[type="range"]::-ms-fill-lower { 162 | background: var(--inputBgColor); 163 | border: 1px solid var(--borderColor); 164 | border-radius: 16px; 165 | } 166 | input[type="range"]::-ms-fill-upper { 167 | background: var(--inputBgColor); 168 | border: 1px solid var(--borderColor); 169 | border-radius: 16px; 170 | } 171 | input[type="range"]::-ms-thumb { 172 | border: 0px solid var(--borderColor); 173 | height: 12px; 174 | width: 12px; 175 | border-radius: 6px; 176 | background: var(--buttonFontColor); 177 | cursor: pointer; 178 | margin-top: 1px; 179 | } 180 | input[type="range"]:focus::-ms-fill-lower { 181 | background: var(--inputBgColor); 182 | } 183 | input[type="range"]:focus::-ms-fill-upper { 184 | background: var(--inputBgColor); 185 | } 186 | -------------------------------------------------------------------------------- /css/Interface.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* Old Color Scheme 3 | --bgColor: rgba(205, 205, 205, 1); 4 | --innerMenuBgColor: rgba(225, 225, 225, 1); 5 | --sliderBgColor: rgba(245, 245, 245, 1); 6 | --fontColor: rgba(25, 25, 25, 1); 7 | --buttonFontColor: #424242; 8 | --buttonBgColor: #9e9e9e; 9 | --buttonActiveBgColor: #d3d3d3; 10 | --buttonHoverBgColor: #c9c9c9; 11 | --borderColor: rgba(0, 0, 0, 0.5); */ 12 | 13 | --bgColor: #424242; 14 | --innerMenuBgColor: #424242; 15 | --lightInnerMenuHoverBgColor: #4e4e4e; 16 | 17 | --inputBgColor: #757575; 18 | --inputFontColor: #eeeeee; 19 | 20 | --fontColor: #bdbdbd; 21 | --fontWeightedColor: #d3d3d3; 22 | --fontWeakenedColor: #8a8a8a; 23 | 24 | --buttonFontColor: #bdbdbd; 25 | --buttonBgColor: #494949; 26 | --buttonActiveBgColor: #757575; 27 | --buttonHoverBgColor: #494949; 28 | --buttonHoverFontColor: #dfdfdf; 29 | 30 | --borderColor: #616161; 31 | } 32 | 33 | html, 34 | body { 35 | font-family: "Source Sans Pro", sans-serif !important; 36 | color: var(--fontColor); 37 | width: 100%; 38 | height: 100%; 39 | min-width: 1040px; 40 | min-height: 100%; 41 | } 42 | body { 43 | overflow-y: hidden; 44 | overflow-x: auto; 45 | margin: 0; 46 | padding: 0; 47 | } 48 | .pcr-app:not(.visible) { 49 | display: none; 50 | } 51 | .navbar { 52 | box-sizing: border-box; 53 | transition: all 0.4s ease-out; 54 | max-height: 150px; 55 | background-color: var(--bgColor); 56 | padding: 0.5em; 57 | } 58 | #minimizeMenu { 59 | position: absolute; 60 | right: 5px; 61 | transition: all 0.4s ease-out; 62 | cursor: pointer; 63 | } 64 | .zoomGroup { 65 | position: absolute; 66 | left: 0px; 67 | bottom: 0px; 68 | } 69 | .zoomGroup:hover, 70 | .pianoCanvas:hover ~ .zoomGroup { 71 | z-index: 1055; 72 | } 73 | canvas { 74 | z-index: -5; 75 | } 76 | #progressBarCanvas { 77 | transition: all 0.4s ease-out; 78 | background-color: var(--inputBgColor); 79 | box-sizing: border-box; 80 | left: 0px; 81 | position: absolute; 82 | border-bottom: 4px solid var(--borderColor); 83 | float: left; 84 | cursor: pointer; 85 | z-index: 10; 86 | } 87 | 88 | .row { 89 | display: flex; 90 | justify-content: space-between; 91 | flex-direction: row; 92 | } 93 | .forcedThinButton { 94 | width: 60px !important; 95 | } 96 | .innerMenuDivsContainer { 97 | position: relative; 98 | width: 100%; 99 | height: 100%; 100 | min-width: 1040px; 101 | background-color: rgba(0, 0, 0, 0); 102 | pointer-events: none; 103 | overflow: none; 104 | } 105 | .innerMenuDiv { 106 | margin-top: 24px; 107 | position: absolute; 108 | box-sizing: border-box; 109 | pointer-events: all; 110 | z-index: 100; 111 | 112 | width: 30%; 113 | height: 100%; 114 | min-width: 300px; 115 | right: 0px; 116 | padding: 5px; 117 | 118 | background-color: var(--bgColor); 119 | box-shadow: -3px 3px 10px 1px rgb(0 0 0, 0.5); 120 | 121 | overflow-y: auto; 122 | overflow-x: hidden; 123 | 124 | transition: all 0.25s ease-out; 125 | } 126 | .innerMenuDiv.hidden { 127 | /* right: -30%; */ 128 | } 129 | 130 | .innerMenuContDiv:first-of-type { 131 | border-top: none; 132 | } 133 | .innerMenuContDiv { 134 | background-color: var(--innerMenuBgColor); 135 | background-image: var(--navBackground); 136 | color: var(--buttonFontColor); 137 | border-top: 1px solid var(--borderColor); 138 | border-radius: 2px; 139 | /* padding: 5px; 140 | margin: 5px; */ 141 | box-sizing: border-box; 142 | overflow: hidden; 143 | 144 | transition: 0.3s all ease-out; 145 | } 146 | .innerMenuContDiv.collapsed { 147 | max-height: 2em; 148 | } 149 | .clickableTitle:not(.glyphicon) { 150 | position: relative; 151 | padding-left: 5px; 152 | height: 2em; 153 | box-sizing: border-box; 154 | } 155 | .clickableTitle:hover { 156 | background-color: var(--lightInnerMenuHoverBgColor); 157 | } 158 | .collapserGlyphSpan { 159 | font-size: 1em; 160 | position: absolute !important; 161 | top: 0.5em !important; 162 | height: 14px; 163 | right: 8px; 164 | } 165 | .centeredMenuDiv { 166 | background-color: var(--bgColor); 167 | max-height: calc(50% - 50px); 168 | top: 24px; 169 | padding: 2em; 170 | position: absolute; 171 | width: 50%; 172 | left: 25%; 173 | overflow-y: auto; 174 | overflow-x: hidden; 175 | border-radius: 5px; 176 | transition: all 0.3s ease-out; 177 | } 178 | .notification { 179 | background-color: var(--bgColor); 180 | max-height: calc(50% - 50px); 181 | top: 30%; 182 | padding: 30px; 183 | position: absolute; 184 | width: 50%; 185 | left: 25%; 186 | overflow-y: auto; 187 | overflow-x: hidden; 188 | border-radius: 5px; 189 | } 190 | .highlighted { 191 | animation: pulse 1.5s infinite; 192 | box-shadow: 0em 0em 5px 5px rgba(0, 0, 0, 0.8); 193 | } 194 | 195 | @-webkit-keyframes pulse { 196 | 0% { 197 | -webkit-box-shadow: 0 0 0 0 rgba(10, 141, 228, 0.4); 198 | } 199 | 50% { 200 | -webkit-box-shadow: 0 0 0 10px rgba(10, 141, 228, 0.4); 201 | } 202 | 100% { 203 | -webkit-box-shadow: 0 0 0 0 rgba(10, 141, 228, 0.4); 204 | } 205 | } 206 | @keyframes pulse { 207 | 0% { 208 | -moz-box-shadow: 0 0 0 0 rgba(10, 141, 228, 0.4); 209 | box-shadow: 0 0 0 0 rgba(10, 141, 228, 0.4); 210 | } 211 | 50% { 212 | -moz-box-shadow: 0 0 0 10px rgba(10, 141, 228, 0.4); 213 | box-shadow: 0 0 0 10px rgba(10, 141, 228, 0.4); 214 | } 215 | 100% { 216 | -moz-box-shadow: 0 0 0 0 rgba(10, 141, 228, 0.4); 217 | box-shadow: 0 0 0 0 rgba(10, 141, 228, 0.4); 218 | } 219 | } 220 | 221 | .centeredBigText { 222 | width: 100%; 223 | text-align: center; 224 | margin-top: 1em; 225 | margin-bottom: 1em; 226 | } 227 | .trackName { 228 | width: 100%; 229 | padding-top: 0.1em; 230 | font-size: 0.8em; 231 | } 232 | .instrumentName { 233 | width: 100%; 234 | text-align: left; 235 | font-size: 0.7em; 236 | padding-left: 0.2em; 237 | } 238 | .divider { 239 | border: 0; 240 | border: 1px solid var(--buttonColor); 241 | } 242 | 243 | .floatSpanLeft span { 244 | float: left; 245 | margin-right: 2px; 246 | } 247 | .container { 248 | width: 100% !important; 249 | display: flex; 250 | } 251 | .halfContainer { 252 | width: 50% !important; 253 | float: left; 254 | } 255 | .row { 256 | width: 100%; 257 | margin: 0; 258 | } 259 | .col { 260 | justify-content: space-between !important; 261 | } 262 | .btn-group { 263 | align-self: auto; 264 | } 265 | .btn-group-vertical { 266 | display: flex; 267 | flex-direction: column; 268 | } 269 | .btn { 270 | position: relative; 271 | box-sizing: border-box; 272 | 273 | padding: 0.5em; 274 | 275 | text-align: center; 276 | font-size: 1em; 277 | font-weight: normal !important; 278 | 279 | color: var(--buttonFontColor); 280 | background-color: var(--buttonBgColor); 281 | 282 | outline: none; 283 | border-radius: 1px; 284 | border: 1px solid rgba(0, 0, 0, 0); 285 | 286 | transition: all 0.15s ease-out; 287 | 288 | -ms-user-select: none; /* Internet Explorer/Edge */ 289 | user-select: none; 290 | } 291 | .btn:hover:active { 292 | background-color: var(--buttonActiveBgColor); 293 | } 294 | .btn:hover { 295 | background-color: var(--buttonHoverBgColor); 296 | color: var(--buttonHoverFontColor); 297 | border: 1px solid var(--borderColor); 298 | } 299 | .btn-select-line { 300 | border-bottom: 4px solid #607d8b; 301 | transition: all 0.15s ease-out; 302 | position: absolute; 303 | bottom: 0; 304 | left: 0; 305 | width: 0%; 306 | opacity: 0; 307 | } 308 | .btn.selected { 309 | border-bottom-left-radius: 0px; 310 | border-bottom-right-radius: 0px; 311 | } 312 | .btn.selected .btn-select-line { 313 | opacity: 1; 314 | width: 100%; 315 | } 316 | .btn-lg { 317 | font-size: 1.5em; 318 | margin-left: 0.2em; 319 | margin-right: 0.2em; 320 | } 321 | .btn span { 322 | font-size: 1em; 323 | } 324 | .pcr-button { 325 | opacity: 0; 326 | position: absolute; 327 | } 328 | 329 | .topContainer { 330 | flex: 1; 331 | display: flex; 332 | justify-content: center; 333 | align-items: center; 334 | } 335 | 336 | .topContainer:first-child { 337 | margin-right: auto; 338 | justify-content: space-between !important; 339 | align-items: unset; 340 | } 341 | 342 | .topContainer:last-child { 343 | margin-left: auto; 344 | justify-content: space-between !important; 345 | align-items: unset; 346 | } 347 | 348 | .vertical-align { 349 | display: flex; 350 | align-items: center; 351 | } 352 | .hidden { 353 | visibility: hidden; 354 | opacity: 0; 355 | pointer-events: none; 356 | } 357 | .unhidden { 358 | visibility: visible; 359 | opacity: 1; 360 | } 361 | .fullscreen { 362 | position: absolute; 363 | top: 0; 364 | left: 0; 365 | width: 100%; 366 | height: 100%; 367 | } 368 | .fullscreen p { 369 | color: var(--inputBgColor); 370 | text-align: center; 371 | position: fixed; 372 | z-index: 999; 373 | overflow: show; 374 | margin: auto; 375 | top: 11.5em; 376 | font-size: 1.5em; 377 | left: 0; 378 | bottom: 0; 379 | right: 0; 380 | height: 50px; 381 | } 382 | 383 | .floatLeft { 384 | float: left; 385 | } 386 | .loadingDiv { 387 | text-align: left; 388 | } 389 | /* Loading Spinner */ 390 | .loader, 391 | .loader:after { 392 | position: fixed; 393 | z-index: 999; 394 | overflow: show; 395 | margin: auto; 396 | top: 150px; 397 | left: 0; 398 | bottom: 0; 399 | right: 0; 400 | width: 50px; 401 | height: 50px; 402 | border-radius: 50%; 403 | width: 10em; 404 | height: 10em; 405 | } 406 | .loader { 407 | font-size: 0.5em; 408 | border-top: 1.1em solid rgba(255, 255, 255, 0.2); 409 | border-right: 1.1em solid rgba(255, 255, 255, 0.2); 410 | border-bottom: 1.1em solid rgba(255, 255, 255, 0.2); 411 | border-left: 1.1em solid #ffffff; 412 | -webkit-transform: translateZ(0); 413 | -ms-transform: translateZ(0); 414 | transform: translateZ(0); 415 | -webkit-animation: load8 1.1s infinite linear; 416 | animation: load8 1.1s infinite linear; 417 | } 418 | @-webkit-keyframes load8 { 419 | 0% { 420 | -webkit-transform: rotate(0deg); 421 | transform: rotate(0deg); 422 | } 423 | 100% { 424 | -webkit-transform: rotate(360deg); 425 | transform: rotate(360deg); 426 | } 427 | } 428 | @keyframes load8 { 429 | 0% { 430 | -webkit-transform: rotate(0deg); 431 | transform: rotate(0deg); 432 | } 433 | 100% { 434 | -webkit-transform: rotate(360deg); 435 | transform: rotate(360deg); 436 | } 437 | } 438 | -------------------------------------------------------------------------------- /css/Settings.css: -------------------------------------------------------------------------------- 1 | .settingsContainer { 2 | position: absolute; 3 | left: 0; 4 | top: 0; 5 | width: 100%; 6 | height: 100%; 7 | overflow-y: hidden; 8 | overflow-x: hidden; 9 | } 10 | .settingsTabButtonContainer { 11 | background-color: var(--innerMenuBgColor); 12 | width: 100%; 13 | top: 0; 14 | left: 0; 15 | height: 2em; 16 | display: flex; 17 | justify-content: space-between; 18 | position: absolute; 19 | } 20 | 21 | .settingsTabButton { 22 | flex: 1 1 auto; 23 | height: 2em; 24 | padding: 0.5em; 25 | box-sizing: border-box; 26 | } 27 | .settingsContentContainer { 28 | height: 100%; 29 | overflow-y: hidden; 30 | overflow-x: hidden; 31 | } 32 | .settingsTabContentContainer { 33 | overflow-y: auto; 34 | overflow-x: hidden; 35 | margin-top: 2em; 36 | display: none; 37 | height: calc(100% - 2em); 38 | } 39 | .settingsTabContentContainer button { 40 | margin: 1.5em; 41 | } 42 | .settingsGroupContainer { 43 | border-top: 2px solid var(--borderColor); 44 | } 45 | 46 | .settingsGroupContainer:first-of-type { 47 | border-top: 0px solid black; 48 | } 49 | .settingsGroupLabel { 50 | font-size: 1em; 51 | padding-left: 0.5em; 52 | padding-top: 0.5em; 53 | padding-bottom: 0.5em; 54 | color: var(--fontColor); 55 | } 56 | .settingContainer { 57 | padding-top: 0.5em; 58 | padding-bottom: 0.5em; 59 | padding-left: 1em; 60 | padding-right: 1em; 61 | width: 100%; 62 | box-sizing: border-box; 63 | background-color: var(--innerMenuBgColor); 64 | transition: 0.2s all ease-out; 65 | } 66 | .settingContainer:hover { 67 | background-color: var(--lightInnerMenuHoverBgColor); 68 | } 69 | -------------------------------------------------------------------------------- /css/bootstrap-theme.min.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bewelge/MIDIano/c86cc1c9c39041a873aa8023589971f90cdc2ae1/css/bootstrap-theme.min.css -------------------------------------------------------------------------------- /css/bootstrap.min.css: -------------------------------------------------------------------------------- 1 | @font-face{font-family:'Glyphicons Halflings';src:url(../fonts/glyphicons-halflings-regular.eot);src:url(../fonts/glyphicons-halflings-regular.eot?#iefix) format('embedded-opentype'),url(../fonts/glyphicons-halflings-regular.woff2) format('woff2'),url(../fonts/glyphicons-halflings-regular.woff) format('woff'),url(../fonts/glyphicons-halflings-regular.ttf) format('truetype'),url(../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular) format('svg')}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.glyphicon-asterisk:before{content:"\002a"}.glyphicon-plus:before{content:"\002b"}.glyphicon-eur:before,.glyphicon-euro:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.glyphicon-cd:before{content:"\e201"}.glyphicon-save-file:before{content:"\e202"}.glyphicon-open-file:before{content:"\e203"}.glyphicon-level-up:before{content:"\e204"}.glyphicon-copy:before{content:"\e205"}.glyphicon-paste:before{content:"\e206"}.glyphicon-alert:before{content:"\e209"}.glyphicon-equalizer:before{content:"\e210"}.glyphicon-king:before{content:"\e211"}.glyphicon-queen:before{content:"\e212"}.glyphicon-pawn:before{content:"\e213"}.glyphicon-bishop:before{content:"\e214"}.glyphicon-knight:before{content:"\e215"}.glyphicon-baby-formula:before{content:"\e216"}.glyphicon-tent:before{content:"\26fa"}.glyphicon-blackboard:before{content:"\e218"}.glyphicon-bed:before{content:"\e219"}.glyphicon-apple:before{content:"\f8ff"}.glyphicon-erase:before{content:"\e221"}.glyphicon-hourglass:before{content:"\231b"}.glyphicon-lamp:before{content:"\e223"}.glyphicon-duplicate:before{content:"\e224"}.glyphicon-piggy-bank:before{content:"\e225"}.glyphicon-scissors:before{content:"\e226"}.glyphicon-bitcoin:before{content:"\e227"}.glyphicon-btc:before{content:"\e227"}.glyphicon-xbt:before{content:"\e227"}.glyphicon-yen:before{content:"\00a5"}.glyphicon-jpy:before{content:"\00a5"}.glyphicon-ruble:before{content:"\20bd"}.glyphicon-rub:before{content:"\20bd"}.glyphicon-scale:before{content:"\e230"}.glyphicon-ice-lolly:before{content:"\e231"}.glyphicon-ice-lolly-tasted:before{content:"\e232"}.glyphicon-education:before{content:"\e233"}.glyphicon-option-horizontal:before{content:"\e234"}.glyphicon-option-vertical:before{content:"\e235"}.glyphicon-menu-hamburger:before{content:"\e236"}.glyphicon-modal-window:before{content:"\e237"}.glyphicon-oil:before{content:"\e238"}.glyphicon-grain:before{content:"\e239"}.glyphicon-sunglasses:before{content:"\e240"}.glyphicon-text-size:before{content:"\e241"}.glyphicon-text-color:before{content:"\e242"}.glyphicon-text-background:before{content:"\e243"}.glyphicon-object-align-top:before{content:"\e244"}.glyphicon-object-align-bottom:before{content:"\e245"}.glyphicon-object-align-horizontal:before{content:"\e246"}.glyphicon-object-align-left:before{content:"\e247"}.glyphicon-object-align-vertical:before{content:"\e248"}.glyphicon-object-align-right:before{content:"\e249"}.glyphicon-triangle-right:before{content:"\e250"}.glyphicon-triangle-left:before{content:"\e251"}.glyphicon-triangle-bottom:before{content:"\e252"}.glyphicon-triangle-top:before{content:"\e253"}.glyphicon-console:before{content:"\e254"}.glyphicon-superscript:before{content:"\e255"}.glyphicon-subscript:before{content:"\e256"}.glyphicon-menu-left:before{content:"\e257"}.glyphicon-menu-right:before{content:"\e258"}.glyphicon-menu-down:before{content:"\e259"}.glyphicon-menu-up:before{content:"\e260"} -------------------------------------------------------------------------------- /css/nano.min.css: -------------------------------------------------------------------------------- 1 | /*! Pickr 1.4.7 MIT | https://github.com/Simonwep/pickr */.pickr{position:relative;overflow:visible;-webkit-transform:translateY(0);transform:translateY(0)}.pickr *{box-sizing:border-box;outline:none;border:none;-webkit-appearance:none}.pcr-app *,.pickr *{box-sizing:border-box;outline:none;border:none;-webkit-appearance:none}.pcr-app button.pcr-active,.pcr-app button:focus,.pcr-app input.pcr-active,.pcr-app input:focus,.pickr button.pcr-active,.pickr button:focus,.pickr input.pcr-active,.pickr input:focus{box-shadow:0 0 0 1px hsla(0,0%,100%,.85),0 0 0 3px currentColor}.pcr-app .pcr-palette,.pcr-app .pcr-slider,.pickr .pcr-palette,.pickr .pcr-slider{-webkit-transition:box-shadow .3s;transition:box-shadow .3s}.pcr-app .pcr-palette:focus,.pcr-app .pcr-slider:focus,.pickr .pcr-palette:focus,.pickr .pcr-slider:focus{box-shadow:0 0 0 1px hsla(0,0%,100%,.85),0 0 0 3px rgba(0,0,0,.25)}.pcr-app{position:fixed;display:-webkit-box;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;flex-direction:column;z-index:10000;border-radius:.1em;background:#fff;opacity:0;visibility:hidden;-webkit-transition:opacity .3s,visibility 0s .3s;transition:opacity .3s,visibility 0s .3s;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;box-shadow:0 .15em 1.5em 0 rgba(0,0,0,.1),0 0 1em 0 rgba(0,0,0,.03);left:0;top:0}.pcr-app.visible{-webkit-transition:opacity .3s;transition:opacity .3s;visibility:visible;opacity:1}.pcr-app .pcr-swatches{display:-webkit-box;display:flex;flex-wrap:wrap;margin-top:.75em}.pcr-app .pcr-swatches.pcr-last{margin:0}@supports (display:grid){.pcr-app .pcr-swatches{display:grid;-webkit-box-align:center;align-items:center;grid-template-columns:repeat(auto-fit,1.75em)}}.pcr-app .pcr-swatches>button{font-size:1em;position:relative;width:calc(1.75em - 5px);height:calc(1.75em - 5px);border-radius:.15em;cursor:pointer;margin:2.5px;flex-shrink:0;justify-self:center;-webkit-transition:all .15s;transition:all .15s;overflow:hidden;background:transparent;z-index:1}.pcr-app .pcr-swatches>button:before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url('data:image/svg+xml;utf8, ');background-size:6px;border-radius:.15em;z-index:-1}.pcr-app .pcr-swatches>button:after{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:currentColor;border:1px solid rgba(0,0,0,.05);border-radius:.15em;box-sizing:border-box}.pcr-app .pcr-swatches>button:hover{-webkit-filter:brightness(1.05);filter:brightness(1.05)}.pcr-app .pcr-interaction{display:-webkit-box;display:flex;flex-wrap:wrap;-webkit-box-align:center;align-items:center;margin:0 -.2em}.pcr-app .pcr-interaction>*{margin:0 .2em}.pcr-app .pcr-interaction input{letter-spacing:.07em;font-size:.75em;text-align:center;cursor:pointer;color:#75797e;background:#f1f3f4;border-radius:.15em;-webkit-transition:all .15s;transition:all .15s;padding:.45em .5em;margin-top:.75em}.pcr-app .pcr-interaction input:hover{-webkit-filter:brightness(.975);filter:brightness(.975)}.pcr-app .pcr-interaction input:focus{box-shadow:0 0 0 1px hsla(0,0%,100%,.85),0 0 0 3px rgba(66,133,244,.75)}.pcr-app .pcr-interaction .pcr-result{color:#75797e;text-align:left;-webkit-box-flex:1;flex:1 1 8em;min-width:8em;-webkit-transition:all .2s;transition:all .2s;border-radius:.15em;background:#f1f3f4;cursor:text}.pcr-app .pcr-interaction .pcr-result::-moz-selection{background:#4285f4;color:#fff}.pcr-app .pcr-interaction .pcr-result::selection{background:#4285f4;color:#fff}.pcr-app .pcr-interaction .pcr-type.active{color:#fff;background:#4285f4}.pcr-app .pcr-interaction .pcr-cancel,.pcr-app .pcr-interaction .pcr-clear,.pcr-app .pcr-interaction .pcr-save{width:auto;color:#fff}.pcr-app .pcr-interaction .pcr-cancel:hover,.pcr-app .pcr-interaction .pcr-clear:hover,.pcr-app .pcr-interaction .pcr-save:hover{-webkit-filter:brightness(.925);filter:brightness(.925)}.pcr-app .pcr-interaction .pcr-save{background:#4285f4}.pcr-app .pcr-interaction .pcr-cancel,.pcr-app .pcr-interaction .pcr-clear{background:#f44250}.pcr-app .pcr-interaction .pcr-cancel:focus,.pcr-app .pcr-interaction .pcr-clear:focus{box-shadow:0 0 0 1px hsla(0,0%,100%,.85),0 0 0 3px rgba(244,66,80,.75)}.pcr-app .pcr-selection .pcr-picker{position:absolute;height:18px;width:18px;border:2px solid #fff;border-radius:100%;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.pcr-app .pcr-selection .pcr-color-chooser,.pcr-app .pcr-selection .pcr-color-opacity,.pcr-app .pcr-selection .pcr-color-palette{position:relative;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;display:-webkit-box;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;flex-direction:column;cursor:grab;cursor:-webkit-grab}.pcr-app .pcr-selection .pcr-color-chooser:active,.pcr-app .pcr-selection .pcr-color-opacity:active,.pcr-app .pcr-selection .pcr-color-palette:active{cursor:grabbing;cursor:-webkit-grabbing}.pcr-app[data-theme=nano]{width:14.25em;max-width:95vw}.pcr-app[data-theme=nano] .pcr-swatches{margin-top:.6em;padding:0 .6em}.pcr-app[data-theme=nano] .pcr-interaction{padding:0 .6em .6em}.pcr-app[data-theme=nano] .pcr-selection{display:grid;grid-gap:.6em;grid-template-columns:1fr 4fr;grid-template-rows:5fr auto auto;-webkit-box-align:center;align-items:center;height:10.5em;width:100%;align-self:flex-start}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview{grid-area:2/1/4/1;height:100%;width:100%;display:-webkit-box;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;flex-direction:row;-webkit-box-pack:center;justify-content:center;margin-left:.6em}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview .pcr-last-color{display:none}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview .pcr-current-color{position:relative;background:currentColor;width:2em;height:2em;border-radius:50em;overflow:hidden}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-preview .pcr-current-color:before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url('data:image/svg+xml;utf8, ');background-size:.5em;border-radius:.15em;z-index:-1}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-palette{grid-area:1/1/2/3;width:100%;height:100%;z-index:1}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-palette .pcr-palette{border-radius:.15em;width:100%;height:100%}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-palette .pcr-palette:before{position:absolute;content:"";top:0;left:0;width:100%;height:100%;background:url('data:image/svg+xml;utf8, ');background-size:.5em;border-radius:.15em;z-index:-1}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser{grid-area:2/2/2/2}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity{grid-area:3/2/3/2}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser,.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity{height:.5em;margin:0 .6em}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser .pcr-picker,.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity .pcr-picker{top:50%;-webkit-transform:translateY(-50%);transform:translateY(-50%)}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser .pcr-slider,.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity .pcr-slider{-webkit-box-flex:1;flex-grow:1;border-radius:50em}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-chooser .pcr-slider{background:-webkit-gradient(linear,left top,right top,from(red),color-stop(#ff0),color-stop(#0f0),color-stop(#0ff),color-stop(#00f),color-stop(#f0f),to(red));background:linear-gradient(90deg,red,#ff0,#0f0,#0ff,#00f,#f0f,red)}.pcr-app[data-theme=nano] .pcr-selection .pcr-color-opacity .pcr-slider{background:-webkit-gradient(linear,left top,right top,from(transparent),to(#000)),url('data:image/svg+xml;utf8, ');background:linear-gradient(90deg,transparent,#000),url('data:image/svg+xml;utf8, ');background-size:100%,.25em} -------------------------------------------------------------------------------- /eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb-base"], 3 | "env": { 4 | "node": true, 5 | "es6": true, 6 | "browser": true 7 | }, 8 | "rules": { 9 | "no-console": "off" 10 | } 11 | } -------------------------------------------------------------------------------- /fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bewelge/MIDIano/c86cc1c9c39041a873aa8023589971f90cdc2ae1/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bewelge/MIDIano/c86cc1c9c39041a873aa8023589971f90cdc2ae1/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bewelge/MIDIano/c86cc1c9c39041a873aa8023589971f90cdc2ae1/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bewelge/MIDIano/c86cc1c9c39041a873aa8023589971f90cdc2ae1/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 51 | 55 | 62 | 69 | 72 | 79 | 86 | 93 | 94 | 97 | 104 | 111 | 118 | 119 | 123 | 128 | 132 | 137 | 141 | 146 | 151 | 156 | 160 | 161 | 172 | 178 | 184 | 185 | 186 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | MIDIano 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /js/InputListeners.js: -------------------------------------------------------------------------------- 1 | import { getPlayer } from "./player/Player.js" 2 | import { getSetting } from "./settings/Settings.js" 3 | 4 | export class InputListeners { 5 | constructor(ui, render) { 6 | this.grabSpeed = [] 7 | this.delay = false 8 | 9 | this.addMouseAndTouchListeners(render, ui) 10 | 11 | document.body.addEventListener("wheel", this.onWheel()) 12 | 13 | this.addProgressBarMouseListeners(render) 14 | 15 | window.addEventListener("keydown", this.onKeyDown(ui)) 16 | 17 | ui.setOnMenuHeightChange(val => render.onMenuHeightChanged(val)) 18 | 19 | ui.fireInitialListeners() 20 | 21 | let player = getPlayer() 22 | render.setPianoInputListeners( 23 | player.addInputNoteOn.bind(player), 24 | player.addInputNoteOff.bind(player) 25 | ) 26 | } 27 | 28 | addMouseAndTouchListeners(render, ui) { 29 | window.addEventListener("mouseup", ev => this.onMouseUp(ev, render)) 30 | document.body.addEventListener( 31 | "mousedown", 32 | ev => this.onMouseDown(ev, render), 33 | { passive: false } 34 | ) 35 | document.body.addEventListener( 36 | "mousemove", 37 | ev => this.onMouseMove(ev, render, ui), 38 | { passive: false } 39 | ) 40 | window.addEventListener("touchend", ev => this.onMouseUp(ev, render), { 41 | passive: false 42 | }) 43 | document.body.addEventListener( 44 | "touchstart", 45 | ev => this.onMouseDown(ev, render), 46 | { passive: false } 47 | ) 48 | document.body.addEventListener( 49 | "touchmove", 50 | ev => this.onMouseMove(ev, render, ui), 51 | { passive: false } 52 | ) 53 | } 54 | 55 | addProgressBarMouseListeners(render) { 56 | render 57 | .getProgressBarCanvas() 58 | .addEventListener("mousemove", this.onMouseMoveProgressCanvas(render)) 59 | render 60 | .getProgressBarCanvas() 61 | .addEventListener("mousedown", this.onMouseDownProgressCanvas(render)) 62 | } 63 | 64 | onWheel() { 65 | return event => { 66 | if (event.target != document.body) { 67 | return 68 | } 69 | if (this.delay) { 70 | return 71 | } 72 | this.delay = true 73 | 74 | let alreadyScrolling = getPlayer().scrolling != 0 75 | 76 | //Because Firefox does not set .wheelDelta 77 | let wheelDelta = event.wheelDelta ? event.wheelDelta : -1 * event.deltaY 78 | 79 | let evDel = 80 | ((wheelDelta + 1) / (Math.abs(wheelDelta) + 1)) * 81 | Math.min(500, Math.abs(wheelDelta)) 82 | 83 | var wheel = (evDel / Math.abs(evDel)) * 500 84 | 85 | getPlayer().scrolling -= 0.001 * wheel 86 | if (!alreadyScrolling) { 87 | getPlayer().handleScroll() 88 | } 89 | this.delay = false 90 | } 91 | } 92 | 93 | onKeyDown(ui) { 94 | return e => { 95 | if (!getPlayer().isFreeplay) { 96 | if (e.code == "Space") { 97 | e.preventDefault() 98 | if (!getPlayer().paused) { 99 | ui.clickPause(e) 100 | } else { 101 | ui.clickPlay(e) 102 | } 103 | } else if (e.code == "ArrowUp") { 104 | getPlayer().increaseSpeed(0.05) 105 | ui.getSpeedDisplayField().value = 106 | Math.floor(getPlayer().playbackSpeed * 100) + "%" 107 | } else if (e.code == "ArrowDown") { 108 | getPlayer().increaseSpeed(-0.05) 109 | ui.getSpeedDisplayField().value = 110 | Math.floor(getPlayer().playbackSpeed * 100) + "%" 111 | } else if (e.code == "ArrowLeft") { 112 | getPlayer().setTime(getPlayer().getTime() - 5) 113 | } else if (e.code == "ArrowRight") { 114 | getPlayer().setTime(getPlayer().getTime() + 5) 115 | } 116 | } 117 | } 118 | } 119 | 120 | onMouseDownProgressCanvas(render) { 121 | return ev => { 122 | ev.preventDefault() 123 | if (ev.target == render.getProgressBarCanvas()) { 124 | this.grabbedProgressBar = true 125 | getPlayer().wasPaused = getPlayer().paused 126 | getPlayer().pause() 127 | let newTime = 128 | (ev.clientX / render.renderDimensions.windowWidth) * 129 | (getPlayer().song.getEnd() / 1000) 130 | 131 | getPlayer().setTime(newTime) 132 | } 133 | } 134 | } 135 | 136 | onMouseMoveProgressCanvas(render) { 137 | return ev => { 138 | if (this.grabbedProgressBar && getPlayer().song) { 139 | let newTime = 140 | (ev.clientX / render.renderDimensions.windowWidth) * 141 | (getPlayer().song.getEnd() / 1000) 142 | getPlayer().setTime(newTime) 143 | } 144 | } 145 | } 146 | 147 | onMouseMove(ev, render, ui) { 148 | let pos = this.getXYFromMouseEvent(ev) 149 | if (this.grabbedProgressBar && getPlayer().song) { 150 | let newTime = 151 | (ev.clientX / render.renderDimensions.windowWidth) * 152 | (getPlayer().song.getEnd() / 1000) 153 | getPlayer().setTime(newTime) 154 | return 155 | } 156 | 157 | if (this.grabbedMainCanvas && getPlayer().song) { 158 | if (this.lastYGrabbed) { 159 | let alreadyScrolling = getPlayer().scrolling != 0 160 | let yChange = 161 | (getSetting("reverseNoteDirection") ? -1 : 1) * 162 | (this.lastYGrabbed - pos.y) 163 | if (!alreadyScrolling) { 164 | getPlayer().setTime( 165 | getPlayer().getTime() - render.getTimeFromHeight(yChange) 166 | ) 167 | this.grabSpeed.push(yChange) 168 | if (this.grabSpeed.length > 3) { 169 | this.grabSpeed.splice(0, 1) 170 | } 171 | } 172 | } 173 | this.lastYGrabbed = pos.y 174 | } 175 | 176 | render.setMouseCoords(ev.clientX, ev.clientY) 177 | 178 | ui.mouseMoved() 179 | } 180 | 181 | onMouseDown(ev, render) { 182 | let pos = this.getXYFromMouseEvent(ev) 183 | if ( 184 | ev.target == document.body && 185 | render.isOnMainCanvas(pos) && 186 | !this.grabbedProgressBar 187 | ) { 188 | getPlayer().wasPaused = getPlayer().paused 189 | ev.preventDefault() 190 | this.grabbedMainCanvas = true 191 | getPlayer().pause() 192 | } 193 | } 194 | 195 | onMouseUp(ev, render) { 196 | let pos = this.getXYFromMouseEvent(ev) 197 | if (ev.target == document.body && render.isOnMainCanvas(pos)) { 198 | ev.preventDefault() 199 | } 200 | if (this.grabSpeed.length) { 201 | getPlayer().scrolling = this.grabSpeed[this.grabSpeed.length - 1] / 50 202 | getPlayer().handleScroll() 203 | this.grabSpeed = [] 204 | } 205 | if (this.grabbedProgressBar || this.grabbedMainCanvas) { 206 | if (!getPlayer().wasPaused) { 207 | getPlayer().resume() 208 | } 209 | } 210 | this.grabbedProgressBar = false 211 | this.grabbedMainCanvas = false 212 | this.lastYGrabbed = false 213 | } 214 | 215 | getXYFromMouseEvent(ev) { 216 | if (ev.clientX == undefined) { 217 | if (ev.touches.length) { 218 | return { 219 | x: ev.touches[ev.touches.length - 1].clientX, 220 | y: ev.touches[ev.touches.length - 1].clientY 221 | } 222 | } else { 223 | return { x: -1, y: -1 } 224 | } 225 | } 226 | return { x: ev.clientX, y: ev.clientY } 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /js/MicInputHandler.js: -------------------------------------------------------------------------------- 1 | class MicInputHandler { 2 | constructor() { 3 | if (navigator.mediaDevices === undefined) { 4 | navigator.mediaDevices = {} 5 | } 6 | 7 | if (navigator.mediaDevices.getUserMedia === undefined) { 8 | navigator.mediaDevices.getUserMedia = function (constraints) { 9 | // First get ahold of the legacy getUserMedia, if present 10 | var getUserMedia = 11 | navigator.webkitGetUserMedia || 12 | navigator.mozGetUserMedia || 13 | navigator.msGetUserMedia 14 | 15 | // Some browsers just don't implement it - return a rejected promise with an error 16 | // to keep a consistent interface 17 | if (!getUserMedia) { 18 | return Promise.reject( 19 | new Error("getUserMedia is not implemented in this browser") 20 | ) 21 | } 22 | 23 | // Otherwise, wrap the call to the old navigator.getUserMedia with a Promise 24 | return new Promise(function (resolve, reject) { 25 | getUserMedia.call(navigator, constraints, resolve, reject) 26 | }) 27 | } 28 | } 29 | this.frequencies = {} 30 | this.lastStrongestFrequency = 0 31 | let audioContext = new (window.AudioContext || window.webkitAudioContext)({ 32 | sampleRate: 8000 33 | }) 34 | var source 35 | var analyser = audioContext.createAnalyser() 36 | analyser.minDecibels = -90 37 | analyser.maxDecibels = -10 38 | analyser.smoothingTimeConstant = 0.5 39 | this.audioContext = audioContext 40 | this.analyser = analyser 41 | 42 | if (navigator.mediaDevices.getUserMedia) { 43 | console.log("getUserMedia supported.") 44 | var constraints = { audio: true } 45 | navigator.mediaDevices 46 | .getUserMedia(constraints) 47 | .then( 48 | function (stream) { 49 | source = audioContext.createMediaStreamSource(stream) 50 | // source.connect(audioContext.destination) 51 | source.connect(analyser) 52 | 53 | // this.getCurrentFrequency() 54 | }.bind(this) 55 | ) 56 | .catch(function (err) { 57 | console.log("The following gUM error occured: " + err) 58 | }) 59 | } 60 | } 61 | getCurrentFrequency() { 62 | this.analyser.fftSize = 2048 63 | var bufferLength = this.analyser.fftSize 64 | var dataArray = new Float32Array(bufferLength) 65 | this.analyser.getFloatTimeDomainData(dataArray) 66 | return this.autoCorrelate(dataArray, this.audioContext.sampleRate) 67 | 68 | // var dataArray = new Uint8Array(bufferLength) 69 | // this.analyser.getByteFrequencyData(dataArray) 70 | // let maxIndex = 0 71 | // let max = -Infinity 72 | // let tot = dataArray.reduce((a, b) => a + b, 0) 73 | // let weightedFrequency = 0 74 | // let strongestFrequency = 0 75 | // let sampleRate = this.audioContext.sampleRate 76 | // dataArray.forEach((value, index) => { 77 | // if (value > max && value > 50) { 78 | // max = value 79 | // maxIndex = index 80 | 81 | // strongestFrequency = (sampleRate / 2) * (index / bufferLength) 82 | 83 | // if (index > 0 && index < bufferLength) { 84 | // let nextFreq = (sampleRate / 2) * ((index + 1) / bufferLength) 85 | // let nextVal = dataArray[index + 1] 86 | // let nextDiff = Math.abs(nextVal - value) 87 | 88 | // let prevFreq = (sampleRate / 2) * ((index - 1) / bufferLength) 89 | // let prevVal = dataArray[index - 1] 90 | // let prevDiff = Math.abs(prevVal - value) 91 | 92 | // let totVals = value + prevVal + nextVal 93 | // let totDiff = nextDiff + prevDiff 94 | 95 | // strongestFrequency = 96 | // (strongestFrequency * value) / totVals + 97 | // (nextVal / totVals) * nextFreq + 98 | // (prevVal / totVals) * prevFreq 99 | // } 100 | // } 101 | // weightedFrequency += 102 | // (value / tot) * (sampleRate / 2) * (index / bufferLength) 103 | // }) 104 | // if (max > 0) { 105 | // console.log(strongestFrequency) 106 | // } 107 | // return strongestFrequency 108 | } 109 | autoCorrelate(buf, sampleRate) { 110 | // Implements the ACF2+ algorithm 111 | var SIZE = buf.length 112 | var rms = 0 113 | 114 | for (var i = 0; i < SIZE; i++) { 115 | var val = buf[i] 116 | rms += val * val 117 | } 118 | rms = Math.sqrt(rms / SIZE) 119 | if (rms < 0.01) 120 | // not enough signal 121 | return -1 122 | 123 | var r1 = 0, 124 | r2 = SIZE - 1, 125 | thres = 0.2 126 | for (var i = 0; i < SIZE / 2; i++) 127 | if (Math.abs(buf[i]) < thres) { 128 | r1 = i 129 | break 130 | } 131 | for (var i = 1; i < SIZE / 2; i++) 132 | if (Math.abs(buf[SIZE - i]) < thres) { 133 | r2 = SIZE - i 134 | break 135 | } 136 | 137 | buf = buf.slice(r1, r2) 138 | SIZE = buf.length 139 | 140 | var c = new Array(SIZE).fill(0) 141 | for (var i = 0; i < SIZE; i++) 142 | for (var j = 0; j < SIZE - i; j++) c[i] = c[i] + buf[j] * buf[j + i] 143 | 144 | var d = 0 145 | while (c[d] > c[d + 1]) d++ 146 | var maxval = -1, 147 | maxpos = -1 148 | for (var i = d; i < SIZE; i++) { 149 | if (c[i] > maxval) { 150 | maxval = c[i] 151 | maxpos = i 152 | } 153 | } 154 | var T0 = maxpos 155 | 156 | var x1 = c[T0 - 1], 157 | x2 = c[T0], 158 | x3 = c[T0 + 1] 159 | let a = (x1 + x3 - 2 * x2) / 2 160 | let b = (x3 - x1) / 2 161 | if (a) T0 = T0 - b / (2 * a) 162 | 163 | return sampleRate / T0 164 | } 165 | frequencyToNote(frequency) { 166 | let note = 12 * (Math.log(frequency / 440) / Math.log(2)) 167 | return Math.round(note) + 48 168 | } 169 | 170 | setupUserMedia() { 171 | if (navigator.mediaDevices === undefined) { 172 | navigator.mediaDevices = {} 173 | } 174 | 175 | if (navigator.mediaDevices.getUserMedia === undefined) { 176 | navigator.mediaDevices.getUserMedia = function (constraints) { 177 | // First get ahold of the legacy getUserMedia, if present 178 | var getUserMedia = 179 | navigator.webkitGetUserMedia || 180 | navigator.mozGetUserMedia || 181 | navigator.msGetUserMedia 182 | 183 | // Some browsers just don't implement it - return a rejected promise with an error 184 | // to keep a consistent interface 185 | if (!getUserMedia) { 186 | return Promise.reject( 187 | new Error("getUserMedia is not implemented in this browser") 188 | ) 189 | } 190 | 191 | // Otherwise, wrap the call to the old navigator.getUserMedia with a Promise 192 | return new Promise(function (resolve, reject) { 193 | getUserMedia.call(navigator, constraints, resolve, reject) 194 | }) 195 | } 196 | } 197 | } 198 | } 199 | 200 | var theMicInputHandler = null // new MicInputHandler() 201 | 202 | export const getMicInputHandler = () => { 203 | return theMicInputHandler 204 | } 205 | export const getCurrentMicFrequency = () => { 206 | if (!theMicInputHandler) return -1 207 | return theMicInputHandler.getCurrentFrequency() 208 | } 209 | 210 | export const getCurrentMicNote = () => { 211 | if (!theMicInputHandler) return -1 212 | return theMidInputHandler.frequencyToNote( 213 | theMicInputHandler.getCurrentFrequency() 214 | ) 215 | } 216 | -------------------------------------------------------------------------------- /js/MidiInputHandler.js: -------------------------------------------------------------------------------- 1 | class MidiInputHandler { 2 | constructor() { 3 | // patch up prefixes 4 | window.AudioContext = window.AudioContext || window.webkitAudioContext 5 | 6 | this.noMidiMessage = 7 | "You will only be able to play Midi-Files. To play along, you need to use a browser with Midi-support, connect a Midi-Device to your computer and reload the page." 8 | this.init() 9 | } 10 | init() { 11 | if (navigator.requestMIDIAccess) 12 | navigator 13 | .requestMIDIAccess() 14 | .then(this.onMIDIInit.bind(this), this.onMIDIReject.bind(this)) 15 | else 16 | alert( 17 | "No MIDI support present in your browser. Check https://developer.mozilla.org/en-US/docs/Web/API/MIDIAccess#Browser_compatibility to see which Browsers support this feature." 18 | ) 19 | } 20 | getAvailableInputDevices() { 21 | try { 22 | return Array.from(this.midiAccess.inputs.values()) 23 | } catch (e) { 24 | return [] 25 | } 26 | } 27 | getAvailableOutputDevices() { 28 | try { 29 | return Array.from(this.midiAccess.outputs.values()) 30 | } catch (e) { 31 | return [] 32 | } 33 | } 34 | setNoteOnCallback(callback) { 35 | this.noteOnCallback = callback 36 | } 37 | addInput(device) { 38 | device.onmidimessage = this.MIDIMessageEventHandler.bind(this) 39 | } 40 | clearInput(device) { 41 | device.onmidimessage = null 42 | } 43 | addOutput(device) { 44 | this.activeOutput = device 45 | } 46 | clearOutput(device) { 47 | if (this.activeOutput == device) { 48 | this.activeOutput = null 49 | } 50 | } 51 | clearInputs() { 52 | Array.from(this.midiAccess.inputs.values()).forEach( 53 | device => (device.onmidimessage = null) 54 | ) 55 | } 56 | isDeviceActive(device) { 57 | return device.onmidimessage != null 58 | } 59 | isOutputDeviceActive(device) { 60 | return this.activeOutput == device 61 | } 62 | onMIDIInit(midi) { 63 | this.midiAccess = midi 64 | } 65 | setNoteOffCallback(callback) { 66 | this.noteOffCallback = callback 67 | } 68 | onMIDIReject(err) { 69 | alert("The MIDI system failed to start. " + this.noMidiMessage) 70 | } 71 | 72 | MIDIMessageEventHandler(event) { 73 | // Mask off the lower nibble (MIDI channel, which we don't care about) 74 | switch (event.data[0] & 0xf0) { 75 | case 0x90: 76 | if (event.data[2] != 0) { 77 | // if velocity != 0 => note-on 78 | this.noteOnCallback(parseInt(event.data[1]) - 21) 79 | return 80 | } 81 | case 0x80: 82 | this.noteOffCallback(parseInt(event.data[1]) - 21) 83 | return 84 | } 85 | } 86 | getActiveMidiOutput() { 87 | return this.activeOutput 88 | } 89 | isOutputActive() { 90 | return this.activeOutput ? true : false 91 | } 92 | isInputActive() { 93 | let devices = this.getAvailableInputDevices() 94 | for (let i = 0; i < devices.length; i++) { 95 | if (this.isDeviceActive(devices[i])) { 96 | return true 97 | } 98 | } 99 | return false 100 | } 101 | playNote(noteNumber, velocity, noteOffVelocity, delayOn, delayOff) { 102 | let noteOnEvent = [0x90, noteNumber, velocity] 103 | let noteOffEvent = [0x80, noteNumber, noteOffVelocity] 104 | this.activeOutput.send(noteOnEvent, window.performance.now() + delayOn) 105 | this.activeOutput.send(noteOffEvent, window.performance.now() + delayOff) 106 | } 107 | midiOutNoteOff() {} 108 | noteOnCallback() {} 109 | noteOffCallback() {} 110 | } 111 | const theMidiHandler = new MidiInputHandler() 112 | export const getMidiHandler = () => { 113 | return theMidiHandler 114 | } 115 | -------------------------------------------------------------------------------- /js/Rendering/BackgroundRender.js: -------------------------------------------------------------------------------- 1 | import { getSetting } from "../settings/Settings.js" 2 | import { isBlack } from "../Util.js" 3 | /** 4 | * Class that renders the background of the main canvas 5 | */ 6 | export class BackgroundRender { 7 | constructor(ctx, renderDimensions) { 8 | this.ctx = ctx 9 | this.renderDimensions = renderDimensions 10 | this.renderDimensions.registerResizeCallback(this.render.bind(this)) 11 | this.render() 12 | } 13 | renderIfColorsChanged() { 14 | if ( 15 | this.col1 != getSetting("bgCol1") || 16 | this.col2 != getSetting("bgCol2") || 17 | this.col3 != getSetting("bgCol3") || 18 | this.pianoPosition != getSetting("pianoPosition") 19 | ) { 20 | this.render() 21 | } 22 | } 23 | render() { 24 | let c = this.ctx 25 | c.clearRect( 26 | 0, 27 | 0, 28 | this.renderDimensions.windowWidth, 29 | this.renderDimensions.windowHeight 30 | ) 31 | 32 | let reversed = getSetting("reverseNoteDirection") 33 | let bgHeight = reversed 34 | ? this.renderDimensions.windowHeight - 35 | this.renderDimensions.getAbsolutePianoPosition() 36 | : this.renderDimensions.getAbsolutePianoPosition() 37 | let bgY = reversed ? this.renderDimensions.getAbsolutePianoPosition() : 0 38 | const col1 = getSetting("bgCol1") 39 | const col2 = getSetting("bgCol2") 40 | const col3 = getSetting("bgCol3") 41 | c.strokeStyle = col1 42 | c.fillStyle = col2 43 | let whiteKey = 0 44 | for (let i = 0; i < 88; i++) { 45 | if (!isBlack(i)) { 46 | c.strokeStyle = col3 47 | c.fillStyle = (i + 2) % 2 ? col1 : col2 48 | c.lineWidth = 1 49 | 50 | let dim = this.renderDimensions.getKeyDimensions(i) 51 | c.fillRect(dim.x, bgY, dim.w, bgHeight) 52 | 53 | if (1 + (whiteKey % 7) == 3) { 54 | c.lineWidth = 2 55 | c.beginPath() 56 | c.moveTo(dim.x, bgY) 57 | c.lineTo(dim.x, bgY + bgHeight) 58 | c.stroke() 59 | c.closePath() 60 | } 61 | whiteKey++ 62 | } 63 | } 64 | this.col1 = col1 65 | this.col2 = col2 66 | this.col3 = col3 67 | this.pianoPosition = getSetting("pianoPosition") 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /js/Rendering/DebugRender.js: -------------------------------------------------------------------------------- 1 | import { CONST } from "../data/CONST.js" 2 | import { getSetting } from "../settings/Settings.js" 3 | import { formatTime } from "../Util.js" 4 | 5 | /** 6 | * Class to render some general debug-info or when mouse is hovered over a note. 7 | */ 8 | export class DebugRender { 9 | constructor(active, ctx, renderDimensions) { 10 | this.noteInfoBoxesToDraw = [] 11 | this.active = active 12 | this.ctx = ctx 13 | this.renderDimensions = renderDimensions 14 | 15 | this.fpsFilterStrength = 5 16 | this.frameTime = 0 17 | this.lastTimestamp = window.performance.now() 18 | } 19 | addNote(note) { 20 | this.noteInfoBoxesToDraw.push(note) 21 | } 22 | render(renderInfos, mouseX, mouseY, menuHeight) { 23 | this.thisTimestamp = window.performance.now() 24 | if (getSetting("showFps")) { 25 | let timePassed = this.thisTimestamp - this.lastTimestamp 26 | this.frameTime += (timePassed - this.frameTime) / this.fpsFilterStrength 27 | this.ctx.font = "20px Arial black" 28 | this.ctx.fillStyle = "rgba(255,255,255,0.8)" 29 | this.ctx.fillText( 30 | (1000 / this.frameTime).toFixed(0) + " FPS", 31 | 20, 32 | menuHeight + 60 33 | ) 34 | } 35 | 36 | this.lastTimestamp = this.thisTimestamp 37 | 38 | this.renderNoteDebugInfo(renderInfos, mouseX, mouseY) 39 | } 40 | renderNoteDebugInfo(renderInfos, mouseX, mouseY) { 41 | if (getSetting("showNoteDebugInfo")) { 42 | let amountOfNotesDrawn = 0 43 | Object.keys(renderInfos).forEach(trackIndex => { 44 | renderInfos[trackIndex].black 45 | .filter(renderInfo => 46 | this.isMouseInRenderInfo(renderInfo, mouseX, mouseY) 47 | ) 48 | .forEach(renderInfo => { 49 | this.drawNoteInfoBox(renderInfo, mouseX, mouseY, amountOfNotesDrawn) 50 | amountOfNotesDrawn++ 51 | }) 52 | renderInfos[trackIndex].white 53 | .filter(renderInfo => 54 | this.isMouseInRenderInfo(renderInfo, mouseX, mouseY) 55 | ) 56 | .forEach(renderInfo => { 57 | this.drawNoteInfoBox(renderInfo, mouseX, mouseY, amountOfNotesDrawn) 58 | amountOfNotesDrawn++ 59 | }) 60 | }) 61 | } 62 | } 63 | isMouseInRenderInfo(renderInfo, mouseX, mouseY) { 64 | return ( 65 | mouseX > renderInfo.x && 66 | mouseX < renderInfo.x + renderInfo.w && 67 | mouseY > renderInfo.y && 68 | mouseY < renderInfo.y + renderInfo.h 69 | ) 70 | } 71 | 72 | drawNoteInfoBox(renderInfo, mouseX, mouseY, amountOfNotesDrawn) { 73 | let c = this.ctx 74 | c.fillStyle = "white" 75 | c.font = "12px Arial black" 76 | c.textBaseline = "top" 77 | c.strokeStyle = renderInfo.fillStyle 78 | c.lineWidth = 4 79 | 80 | let lines = [ 81 | "Note: " + CONST.MIDI_NOTE_TO_KEY[renderInfo.noteNumber], 82 | "NoteNumber: " + renderInfo.noteNumber, 83 | "MidiNoteNumber: " + renderInfo.midiNoteNumber, 84 | "Start: " + renderInfo.timestamp, 85 | "End: " + renderInfo.offTime, 86 | "Duration: " + renderInfo.duration, 87 | "Velocity: " + renderInfo.velocity, 88 | "Instrument: " + renderInfo.instrument, 89 | "Track: " + renderInfo.track, 90 | "Channel: " + renderInfo.channel 91 | ] 92 | let left = mouseX > this.renderDimensions.windowWidth / 2 ? -160 : 60 93 | let top = 94 | mouseY > this.renderDimensions.windowHeight / 2 95 | ? -10 - 14 * lines.length 96 | : 10 97 | 98 | top += amountOfNotesDrawn * lines.length * 15 99 | c.beginPath() 100 | c.moveTo(mouseX + left - 4, mouseY + top) 101 | c.lineTo(mouseX + left - 4, mouseY + top + lines.length * 14) 102 | c.stroke() 103 | for (let l in lines) { 104 | c.fillText(lines[l], mouseX + left, mouseY + top + 14 * l) 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /js/Rendering/InSongTextRenderer.js: -------------------------------------------------------------------------------- 1 | import { getCurrentSong } from "../player/Player.js" 2 | import { getSetting } from "../settings/Settings.js" 3 | 4 | /** 5 | * Class to render the markers in the midi-song 6 | */ 7 | export class InSongTextRenderer { 8 | constructor(ctx, renderDimensions) { 9 | this.ctx = ctx 10 | this.renderDimensions = renderDimensions 11 | } 12 | render(time) { 13 | if (time > -0.7) return 14 | 15 | let c = this.ctx 16 | c.fillStyle = "rgba(255,255,255,0.8)" 17 | c.strokeStyle = "rgba(255,255,255,0.8)" 18 | c.font = "40px Arial black" 19 | c.textBaseline = "top" 20 | c.lineWidth = 1.5 21 | let text = getCurrentSong().name 22 | let y = this.renderDimensions.getYForTime(-700 - time * 1000) 23 | let txtWd = c.measureText(text).width 24 | c.fillText(text, this.renderDimensions.windowWidth / 2 - txtWd / 2, y + 3) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /js/Rendering/MarkerRenderer.js: -------------------------------------------------------------------------------- 1 | import { getSetting } from "../settings/Settings.js" 2 | 3 | /** 4 | * Class to render the markers in the midi-song 5 | */ 6 | export class MarkerRenderer { 7 | constructor(ctx, renderDimensions) { 8 | this.ctx = ctx 9 | this.renderDimensions = renderDimensions 10 | } 11 | render(time, markers) { 12 | if (getSetting("showMarkersSong")) { 13 | let lookAheadTime = Math.ceil( 14 | time + this.renderDimensions.getSecondsDisplayedBefore() + 1 15 | ) 16 | 17 | let c = this.ctx 18 | c.fillStyle = "rgba(255,255,255,0.8)" 19 | c.strokeStyle = "rgba(255,255,255,0.8)" 20 | c.font = "25px Arial black" 21 | c.textBaseline = "top" 22 | c.lineWidth = 1.5 23 | markers.forEach(marker => { 24 | if ( 25 | marker.timestamp / 1000 >= time && 26 | marker.timestamp / 1000 < lookAheadTime 27 | ) { 28 | let y = this.renderDimensions.getYForTime( 29 | marker.timestamp - time * 1000 30 | ) 31 | let txtWd = c.measureText(marker.text).width 32 | c.fillText( 33 | marker.text, 34 | this.renderDimensions.windowWidth / 2 - txtWd / 2, 35 | y + 3 36 | ) 37 | c.beginPath() 38 | c.moveTo(0, y) 39 | c.lineTo(this.renderDimensions.windowWidth, y) 40 | c.closePath() 41 | c.stroke() 42 | } 43 | }) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /js/Rendering/MeasureLinesRender.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Class to render measure lines on the main canvas. 3 | */ 4 | export class MeasureLinesRender { 5 | constructor(ctx, renderDimensions) { 6 | this.ctx = ctx 7 | this.renderDimensions = renderDimensions 8 | } 9 | render(time, measureLines) { 10 | let ctx = this.ctx 11 | 12 | ctx.strokeStyle = "rgba(255,255,255,0.3)" 13 | 14 | ctx.lineWidth = 0.5 15 | let currentSecond = Math.floor(time) 16 | ctx.beginPath() 17 | let firstSecondShown = 18 | currentSecond - this.renderDimensions.getSecondsDisplayedAfter() - 1 19 | let lastSecondShown = 20 | currentSecond + this.renderDimensions.getSecondsDisplayedBefore() + 1 21 | for (let i = firstSecondShown; i < lastSecondShown; i++) { 22 | if (!measureLines[i]) { 23 | continue 24 | } 25 | measureLines[i].forEach(tempoLine => { 26 | let ht = this.renderDimensions.getYForTime(tempoLine - time * 1000) 27 | 28 | ctx.moveTo(0, ht) 29 | ctx.lineTo(this.renderDimensions.windowWidth, ht) 30 | }) 31 | } 32 | ctx.closePath() 33 | ctx.stroke() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /js/Rendering/NoteParticleRender.js: -------------------------------------------------------------------------------- 1 | import { getSetting } from "../settings/Settings.js" 2 | 3 | /** 4 | * Particles handler 5 | */ 6 | export class NoteParticleRender { 7 | constructor(ctx, renderDimensions) { 8 | this.ctx = ctx 9 | this.renderDimensions = renderDimensions 10 | this.particles = new Map() 11 | } 12 | createParticles(x, y, w, h, color, velocity) { 13 | let amnt = getSetting("particleAmount") 14 | if (getSetting("showParticlesTop")) { 15 | for (let i = 0; i < Math.random() * 0.5 * amnt + 0.5 * amnt; i++) { 16 | let rndX = x + 3 + w * 0.5 + w * 0.5 * (-1 + 2 * Math.random()) 17 | let motX = 18 | (Math.random() - Math.random()) * 0.5 * getSetting("particleSpeed") 19 | let motY = 20 | (-Math.random() * getSetting("particleSpeed") * velocity) / 127 21 | let radius = 22 | (0.5 + 0.5 * Math.random()) * getSetting("particleSize") + 0.5 23 | rndX -= radius / 2 24 | let lifeTime = Math.random() * getSetting("particleLife") + 2 25 | this.createParticle(rndX, y, motX, motY, radius, color, lifeTime) 26 | } 27 | } 28 | if (getSetting("showParticlesBottom")) { 29 | for (let i = 0; i < Math.random() * 0.5 * amnt + 0.5 * amnt; i++) { 30 | let rndX = x + 3 + w * 0.5 + w * 0.5 * (-1 + 2 * Math.random()) 31 | let motX = 32 | (Math.random() - Math.random()) * 0.5 * getSetting("particleSpeed") 33 | let motY = 34 | (-Math.random() * getSetting("particleSpeed") * velocity) / 127 35 | let radius = 36 | (0.5 + 0.5 * Math.random()) * getSetting("particleSize") + 0.5 37 | rndX -= radius / 2 38 | let lifeTime = Math.random() * getSetting("particleLife") + 2 39 | this.createParticle( 40 | rndX, 41 | y + h, 42 | motX, 43 | -1 * motY * 0.5, 44 | radius, 45 | color, 46 | lifeTime 47 | ) 48 | } 49 | } 50 | } 51 | createParticle(x, y, motX, motY, radius, color, lifeTime) { 52 | if (!this.particles.has(color)) { 53 | this.particles.set(color, []) 54 | } 55 | this.particles.get(color).push([x, y, motX, motY, radius, lifeTime, 0]) 56 | } 57 | updateParticles() { 58 | this.particles.forEach(particleArray => 59 | particleArray.forEach(particle => this.updateParticle(particle)) 60 | ) 61 | this.clearDeadParticles() 62 | } 63 | clearDeadParticles() { 64 | this.particles.forEach((particleArray, color) => { 65 | for (let i = particleArray.length - 1; i >= 0; i--) { 66 | if (particleArray[i][6] >= particleArray[i][5]) { 67 | particleArray.splice(i, 1) 68 | } 69 | } 70 | if (particleArray.length == 0) { 71 | this.particles.delete(color) 72 | } 73 | }) 74 | } 75 | 76 | updateParticle(particle) { 77 | particle[0] += particle[2] 78 | particle[1] += particle[3] 79 | 80 | // particle[3] *= 1 + (particle[6] / particle[5]) * 0.05 81 | particle[3] += (particle[6] / particle[5]) * 0.3 82 | 83 | //dampen xy-motion 84 | particle[2] *= 0.95 85 | // particle[3] *= 0.92 86 | 87 | //particle lifetime ticker 88 | particle[6] += particle[4] * 0.1 89 | } 90 | render() { 91 | let stroke = getSetting("particleStroke") 92 | this.ctx.globalAlpha = 0.5 93 | if (stroke) { 94 | this.ctx.strokeStyle = "rgba(255,255,255,0.8)" 95 | this.ctx.lineWidth = 0.5 96 | } 97 | this.particles.forEach((particleArray, color) => { 98 | let c = this.ctx 99 | c.fillStyle = color 100 | c.beginPath() 101 | particleArray.forEach(particle => this.renderParticle(particle)) 102 | c.fill() 103 | if (stroke) { 104 | c.stroke() 105 | } 106 | c.closePath() 107 | }) 108 | this.updateParticles() 109 | this.ctx.globalAlpha = 1 110 | } 111 | renderParticle(particle) { 112 | this.ctx.moveTo(particle[0], particle[1]) 113 | let rad = Math.max(0.1, (1 - particle[6] / particle[5]) * particle[4]) 114 | this.ctx.arc(particle[0], particle[1], rad, 0, Math.PI * 2, 0) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /js/Rendering/OverlayRender.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Class to display message-overlays on screen 3 | */ 4 | export class OverlayRender { 5 | constructor(ctx, renderDimensions) { 6 | this.ctx = ctx 7 | this.renderDimensions = renderDimensions 8 | this.overlays = [] 9 | } 10 | /** 11 | * add a overlay-message to the screen 12 | * 13 | * @param {String} message 14 | * @param {Number} duration 15 | */ 16 | addOverlay(message, duration) { 17 | let totalDuration = duration 18 | this.overlays.push({ message, totalDuration, duration }) 19 | } 20 | /** 21 | * Render / Update the overlays. 22 | */ 23 | render() { 24 | for (let i = this.overlays.length - 1; i >= 0; i--) { 25 | let overlay = this.overlays[i] 26 | overlay.duration-- 27 | if (overlay.duration < 0) { 28 | this.overlays.splice(i, 1) 29 | } 30 | } 31 | 32 | if (this.overlays.length) { 33 | this.globalAlpha = this.setAlphaForOverlay( 34 | this.overlays[this.overlays.length - 1] 35 | ) 36 | this.ctx.fillStyle = "rgba(0,0,0,0.9)" 37 | this.ctx.fillRect( 38 | 0, 39 | 0, 40 | this.renderDimensions.windowWidth, 41 | this.renderDimensions.windowHeight 42 | ) 43 | } 44 | for (let i = 0; i < this.overlays.length; i++) { 45 | let overlay = this.overlays[i] 46 | 47 | this.setAlphaForOverlay(overlay) 48 | 49 | this.ctx.font = "32px 'Source Sans Pro', sans-serif" 50 | this.ctx.fillStyle = "white" 51 | 52 | let wd = this.ctx.measureText(overlay.message).width 53 | this.ctx.fillText( 54 | overlay.message, 55 | this.renderDimensions.windowWidth / 2 - wd / 2, 56 | this.renderDimensions.windowHeight / 4 + i * 40 57 | ) 58 | } 59 | this.ctx.globalAlpha = 1 60 | } 61 | 62 | setAlphaForOverlay(overlay) { 63 | let ratio = 1 - overlay.duration / overlay.totalDuration 64 | if (ratio < 0.1) { 65 | this.ctx.globalAlpha = ratio / 0.1 66 | } else { 67 | this.ctx.globalAlpha = (0.9 - (ratio - 0.1)) / 0.9 68 | } 69 | return ratio 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /js/Rendering/PianoParticleRender.js: -------------------------------------------------------------------------------- 1 | import { getSetting } from "../settings/Settings.js" 2 | 3 | const PARTICLE_LIFE_TIME = 22 4 | 5 | export class PianoParticleRender { 6 | constructor(ctxWhite, ctxBlack, renderDimensions) { 7 | this.ctxWhite = ctxWhite 8 | this.ctxBlack = ctxBlack 9 | this.renderDimensions = renderDimensions 10 | this.particles = { 11 | white: new Map(), 12 | black: new Map() 13 | } 14 | } 15 | add(noteRenderinfo, isWhite) { 16 | let keyDims = this.renderDimensions.getKeyDimensions( 17 | noteRenderinfo.noteNumber 18 | ) 19 | 20 | let color = noteRenderinfo.fillStyle 21 | 22 | let keyColor = noteRenderinfo.isBlack ? "black" : "white" 23 | 24 | if (!this.particles[keyColor].has(color)) { 25 | this.particles[keyColor].set(color, []) 26 | } 27 | this.particles[keyColor] 28 | .get(color) 29 | .push([ 30 | keyDims.x, 31 | 0, 32 | keyDims.w, 33 | keyDims.h, 34 | (PARTICLE_LIFE_TIME * noteRenderinfo.velocity) / 127 35 | ]) 36 | return 37 | } 38 | 39 | updateParticles() { 40 | this.particles.white.forEach(particleArray => 41 | particleArray.forEach(particle => this.updateParticle(particle)) 42 | ) 43 | this.particles.black.forEach(particleArray => 44 | particleArray.forEach(particle => this.updateParticle(particle)) 45 | ) 46 | this.clearDeadParticles() 47 | } 48 | clearDeadParticles() { 49 | this.particles.white.forEach((particleArray, color) => { 50 | for (let i = particleArray.length - 1; i >= 0; i--) { 51 | if (particleArray[i][4] < 0) { 52 | particleArray.splice(i, 1) 53 | } 54 | } 55 | if (particleArray.length == 0) { 56 | this.particles.white.delete(color) 57 | } 58 | }) 59 | this.particles.black.forEach((particleArray, color) => { 60 | for (let i = particleArray.length - 1; i >= 0; i--) { 61 | if (particleArray[i][4] < 0) { 62 | particleArray.splice(i, 1) 63 | } 64 | } 65 | if (particleArray.length == 0) { 66 | this.particles.black.delete(color) 67 | } 68 | }) 69 | } 70 | 71 | updateParticle(particle) { 72 | particle[4]-- 73 | } 74 | render() { 75 | this.particles.white.forEach((particleArray, color) => { 76 | let c = this.ctxWhite 77 | c.strokeStyle = "rgba(255,255,255,0.4)" 78 | c.lineWidth = 2 79 | c.beginPath() 80 | particleArray.forEach(particle => this.renderParticle(particle, c)) 81 | c.stroke() 82 | c.closePath() 83 | }) 84 | this.particles.black.forEach((particleArray, color) => { 85 | let c = this.ctxBlack 86 | c.strokeStyle = "rgba(255,255,255,0.4)" 87 | c.lineWidth = 2 88 | c.beginPath() 89 | particleArray.forEach(particle => this.renderParticle(particle, c)) 90 | c.stroke() 91 | c.closePath() 92 | }) 93 | this.updateParticles() 94 | } 95 | renderParticle(particle, ctx) { 96 | let doneRat = 1 - particle[4] / PARTICLE_LIFE_TIME 97 | let wdRatio = (doneRat - 0.1) * particle[2] * 0.3 98 | ctx.moveTo(particle[0] - wdRatio / 2, 5) 99 | ctx.lineTo(particle[0] - wdRatio / 2, particle[3]) 100 | 101 | ctx.moveTo(particle[0] - wdRatio / 2 + particle[2] + wdRatio, 5) 102 | ctx.lineTo(particle[0] - wdRatio / 2 + particle[2] + wdRatio, particle[3]) 103 | 104 | // ctx.rect( 105 | // particle[0] + doneRat / 2, 106 | // particle[1], 107 | // particle[2] - doneRat, 108 | // particle[3] 109 | // ) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /js/Rendering/ProgressBarRender.js: -------------------------------------------------------------------------------- 1 | import { getSetting } from "../settings/Settings.js" 2 | import { formatTime } from "../Util.js" 3 | /** 4 | * Renders the progress bar of the song 5 | */ 6 | export class ProgressBarRender { 7 | constructor(ctx, renderDimensions) { 8 | this.ctx = ctx 9 | this.ctx.canvas.addEventListener( 10 | "mousemove", 11 | function (ev) { 12 | this.mouseX = ev.clientX 13 | }.bind(this) 14 | ) 15 | this.ctx.canvas.addEventListener( 16 | "mouseleave", 17 | function (ev) { 18 | this.mouseX = -1000 19 | }.bind(this) 20 | ) 21 | this.renderDimensions = renderDimensions 22 | } 23 | render(time, end, markers) { 24 | this.ctx.clearRect( 25 | 0, 26 | 0, 27 | this.renderDimensions.windowWidth, 28 | this.renderDimensions.windowHeight 29 | ) 30 | let ctx = this.ctx 31 | let progressPercent = time / (end / 1000) 32 | ctx.fillStyle = "rgba(80,80,80,0.8)" 33 | ctx.fillRect(this.renderDimensions.windowWidth * progressPercent, 0, 2, 20) 34 | ctx.fillStyle = "rgba(50,150,50,0.8)" 35 | ctx.fillRect(0, 0, this.renderDimensions.windowWidth * progressPercent, 20) 36 | 37 | let isShowingAMarker = false 38 | 39 | if (getSetting("showMarkersTimeline")) { 40 | markers.forEach(marker => { 41 | let xPos = (marker.timestamp / end) * this.renderDimensions.windowWidth 42 | if (Math.abs(xPos - this.mouseX) < 10) { 43 | isShowingAMarker = true 44 | let txtWd = ctx.measureText(marker.text).width 45 | ctx.fillStyle = "black" 46 | ctx.fillText( 47 | marker.text, 48 | Math.max( 49 | 5, 50 | Math.min( 51 | this.renderDimensions.windowWidth - txtWd - 5, 52 | xPos - txtWd / 2 53 | ) 54 | ), 55 | 15 56 | ) 57 | } else { 58 | ctx.strokeStyle = "black" 59 | ctx.lineWidth = 2 60 | ctx.beginPath() 61 | ctx.moveTo(xPos, 0) 62 | ctx.lineTo(xPos, 25) 63 | 64 | ctx.closePath() 65 | ctx.stroke() 66 | } 67 | }) 68 | } 69 | 70 | if (!isShowingAMarker) { 71 | ctx.fillStyle = "rgba(0,0,0,1)" 72 | let showMilis = getSetting("showMiliseconds") 73 | let text = 74 | formatTime(Math.min(time, end), showMilis) + 75 | " / " + 76 | formatTime(end / 1000, showMilis) 77 | let wd = ctx.measureText(text).width 78 | ctx.font = "14px Arial black" 79 | ctx.fillText(text, this.renderDimensions.windowWidth / 2 - wd / 2, 15) 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /js/Rendering/Render.js: -------------------------------------------------------------------------------- 1 | import { DomHelper } from "../ui/DomHelper.js" 2 | import { PianoRender } from "./PianoRender.js" 3 | import { DebugRender } from "./DebugRender.js" 4 | import { OverlayRender } from "./OverlayRender.js" 5 | import { NoteRender } from "./NoteRender.js" 6 | import { SustainRender } from "./SustainRenderer.js" 7 | import { MarkerRenderer } from "./MarkerRenderer.js" 8 | import { RenderDimensions } from "./RenderDimensions.js" 9 | import { BackgroundRender } from "./BackgroundRender.js" 10 | import { MeasureLinesRender } from "./MeasureLinesRender.js" 11 | import { ProgressBarRender } from "./ProgressBarRender.js" 12 | import { getSetting, setSettingCallback } from "../settings/Settings.js" 13 | import { isBlack } from "../Util.js" 14 | import { getTrackColor, isTrackDrawn } from "../player/Tracks.js" 15 | import { getPlayerState } from "../player/Player.js" 16 | import { InSongTextRenderer } from "./InSongTextRenderer.js" 17 | 18 | const DEBUG = true 19 | 20 | const DEFAULT_LOOK_BACK_TIME = 4 21 | const LOOK_AHEAD_TIME = 10 22 | 23 | const PROGRESS_BAR_CANVAS_HEIGHT = 20 24 | 25 | /** 26 | * Class that handles all rendering 27 | */ 28 | export class Render { 29 | constructor() { 30 | this.renderDimensions = new RenderDimensions() 31 | this.renderDimensions.registerResizeCallback(this.setupCanvases.bind(this)) 32 | 33 | setSettingCallback("particleBlur", this.setCtxBlur.bind(this)) 34 | 35 | this.setupCanvases() 36 | 37 | this.pianoRender = new PianoRender(this.renderDimensions) 38 | 39 | this.overlayRender = new OverlayRender(this.ctx, this.renderDimensions) 40 | // this.addStartingOverlayMessage() 41 | 42 | this.debugRender = new DebugRender(DEBUG, this.ctx, this.renderDimensions) 43 | this.noteRender = new NoteRender( 44 | this.ctx, 45 | this.ctxForeground, 46 | this.renderDimensions, 47 | this.pianoRender 48 | ) 49 | this.sustainRender = new SustainRender(this.ctx, this.renderDimensions) 50 | this.markerRender = new MarkerRenderer(this.ctx, this.renderDimensions) 51 | this.inSongTextRender = new InSongTextRenderer( 52 | this.ctx, 53 | this.renderDimensions 54 | ) 55 | 56 | this.measureLinesRender = new MeasureLinesRender( 57 | this.ctx, 58 | this.renderDimensions 59 | ) 60 | 61 | this.progressBarRender = new ProgressBarRender( 62 | this.progressBarCtx, 63 | this.renderDimensions 64 | ) 65 | 66 | this.backgroundRender = new BackgroundRender( 67 | this.ctxBG, 68 | this.renderDimensions 69 | ) 70 | 71 | this.mouseX = 0 72 | this.mouseY = 0 73 | 74 | this.playerState = getPlayerState() 75 | 76 | this.showKeyNamesOnPianoWhite = getSetting("showKeyNamesOnPianoWhite") 77 | this.showKeyNamesOnPianoBlack = getSetting("showKeyNamesOnPianoBlack") 78 | } 79 | 80 | setCtxBlur() { 81 | let blurPx = parseInt(getSetting("particleBlur")) 82 | if (blurPx == 0) { 83 | this.ctxForeground.filter = "none" 84 | } else { 85 | this.ctxForeground.filter = "blur(" + blurPx + "px)" 86 | } 87 | } 88 | setPianoInputListeners(onNoteOn, onNoteOff) { 89 | this.pianoRender.setPianoInputListeners(onNoteOn, onNoteOff) 90 | } 91 | 92 | /** 93 | * Main rendering function 94 | */ 95 | render(playerState) { 96 | this.playerState = playerState 97 | this.ctx.clearRect( 98 | 0, 99 | 0, 100 | this.renderDimensions.windowWidth, 101 | this.renderDimensions.windowHeight 102 | ) 103 | this.ctxForeground.clearRect( 104 | 0, 105 | 0, 106 | this.renderDimensions.windowWidth, 107 | this.renderDimensions.windowHeight 108 | ) 109 | 110 | this.pianoRender.clearPlayedKeysCanvases() 111 | if ( 112 | this.showKeyNamesOnPianoWhite != getSetting("showKeyNamesOnPianoWhite") || 113 | this.showKeyNamesOnPianoBlack != getSetting("showKeyNamesOnPianoBlack") 114 | ) { 115 | this.showKeyNamesOnPianoWhite = getSetting("showKeyNamesOnPianoWhite") 116 | this.showKeyNamesOnPianoBlack = getSetting("showKeyNamesOnPianoBlack") 117 | this.pianoRender.resize() 118 | } 119 | 120 | if ( 121 | this.renderDimensions.pianoPositionY != 122 | parseInt(getSetting("pianoPosition")) 123 | ) { 124 | this.renderDimensions.pianoPositionY = parseInt( 125 | getSetting("pianoPosition") 126 | ) 127 | this.pianoRender.repositionCanvases() 128 | } 129 | this.backgroundRender.renderIfColorsChanged() 130 | 131 | let renderInfosByTrackMap = this.getRenderInfoByTrackMap(playerState) 132 | let inputActiveRenderInfos = this.getInputActiveRenderInfos(playerState) 133 | let inputPlayedRenderInfos = this.getInputPlayedRenderInfos(playerState) 134 | const time = this.getRenderTime(playerState) 135 | const end = playerState.end 136 | if (!playerState.loading && playerState.song) { 137 | const measureLines = playerState.song 138 | ? playerState.song.getMeasureLines() 139 | : [] 140 | 141 | this.progressBarRender.render(time, end, playerState.song.markers) 142 | this.measureLinesRender.render(time, measureLines) 143 | this.sustainRender.render( 144 | time, 145 | playerState.song.sustainsBySecond, 146 | playerState.song.sustainPeriods 147 | ) 148 | 149 | this.noteRender.render( 150 | time, 151 | renderInfosByTrackMap, 152 | inputActiveRenderInfos, 153 | inputPlayedRenderInfos 154 | ) 155 | this.markerRender.render(time, playerState.song.markers) 156 | this.inSongTextRender.render(time, playerState.song.markers) 157 | } 158 | 159 | this.overlayRender.render() 160 | 161 | this.debugRender.render( 162 | renderInfosByTrackMap, 163 | this.mouseX, 164 | this.mouseY, 165 | this.renderDimensions.menuHeight 166 | ) 167 | 168 | if (getSetting("showBPM")) { 169 | this.drawBPM(playerState) 170 | } 171 | } 172 | /** 173 | * Returns current time adjusted for the render-offset from the settings 174 | * @param {Object} playerState 175 | */ 176 | getRenderTime(playerState) { 177 | return playerState.time + getSetting("renderOffset") / 1000 178 | } 179 | getRenderInfoByTrackMap(playerState) { 180 | let renderInfoByTrackMap = {} 181 | if (playerState) 182 | if (playerState.song) { 183 | playerState.song.activeTracks.forEach((track, trackIndex) => { 184 | if (isTrackDrawn(trackIndex)) { 185 | renderInfoByTrackMap[trackIndex] = { black: [], white: [] } 186 | 187 | let time = this.getRenderTime(playerState) 188 | let firstSecondShown = Math.floor( 189 | time - this.renderDimensions.getSecondsDisplayedAfter() - 4 190 | ) 191 | let lastSecondShown = Math.ceil( 192 | time + this.renderDimensions.getSecondsDisplayedBefore() 193 | ) 194 | 195 | for (let i = firstSecondShown; i < lastSecondShown; i++) { 196 | if (track.notesBySeconds[i]) { 197 | track.notesBySeconds[i] 198 | // .filter(note => note.instrument != "percussion") 199 | .map(note => this.getNoteRenderInfo(note, time)) 200 | .forEach(renderInfo => 201 | renderInfo.isBlack 202 | ? renderInfoByTrackMap[trackIndex].black.push(renderInfo) 203 | : renderInfoByTrackMap[trackIndex].white.push(renderInfo) 204 | ) 205 | } 206 | } 207 | } 208 | }) 209 | } 210 | return renderInfoByTrackMap 211 | } 212 | getInputActiveRenderInfos(playerState) { 213 | let inputRenderInfos = [] 214 | for (let key in playerState.inputActiveNotes) { 215 | let activeInputNote = playerState.inputActiveNotes[key] 216 | inputRenderInfos.push( 217 | this.getNoteRenderInfo( 218 | { 219 | timestamp: activeInputNote.timestamp, 220 | noteNumber: activeInputNote.noteNumber, 221 | offTime: playerState.ctxTime * 1000 + 0, 222 | duration: playerState.ctxTime * 1000 - activeInputNote.timestamp, 223 | velocity: 127, 224 | fillStyle: getSetting("inputNoteColor") 225 | }, 226 | playerState.ctxTime 227 | ) 228 | ) 229 | } 230 | return inputRenderInfos 231 | } 232 | getInputPlayedRenderInfos(playerState) { 233 | let inputRenderInfos = [] 234 | for (let key in playerState.inputPlayedNotes) { 235 | let playedInputNote = playerState.inputPlayedNotes[key] 236 | inputRenderInfos.push( 237 | this.getNoteRenderInfo( 238 | { 239 | timestamp: playedInputNote.timestamp, 240 | noteNumber: playedInputNote.noteNumber, 241 | offTime: playedInputNote.offTime, 242 | duration: playerState.ctxTime * 1000 - playedInputNote.timestamp, 243 | velocity: 127, 244 | fillStyle: getSetting("inputNoteColor") 245 | }, 246 | playerState.ctxTime 247 | ) 248 | ) 249 | } 250 | return inputRenderInfos 251 | } 252 | getNoteRenderInfo(note, time) { 253 | time *= 1000 254 | let noteDims = this.renderDimensions.getNoteDimensions( 255 | note.noteNumber, 256 | time, 257 | note.timestamp, 258 | note.offTime, 259 | note.sustainOffTime 260 | ) 261 | let isOn = note.timestamp < time && note.offTime > time ? 1 : 0 262 | let elapsedTime = Math.max(0, time - note.timestamp) 263 | let noteDoneRatio = elapsedTime / note.duration 264 | 265 | let isKeyBlack = isBlack(note.noteNumber) 266 | //TODO Clean up. Right now it returns more info than necessary to use in DebugRender.. 267 | return { 268 | noteNumber: note.noteNumber, 269 | timestamp: note.timestamp, 270 | offTime: note.offTime, 271 | duration: note.duration, 272 | instrument: note.instrument, 273 | track: note.track, 274 | channel: note.channel, 275 | fillStyle: note.fillStyle 276 | ? note.fillStyle 277 | : isKeyBlack 278 | ? getTrackColor(note.track).black 279 | : getTrackColor(note.track).white, 280 | currentTime: time, 281 | isBlack: isKeyBlack, 282 | noteDims: noteDims, 283 | isOn: isOn, 284 | noteDoneRatio: noteDoneRatio, 285 | rad: noteDims.rad, 286 | x: noteDims.x + 1, 287 | y: noteDims.y, 288 | w: noteDims.w - 2, 289 | h: noteDims.h, 290 | sustainH: noteDims.sustainH, 291 | sustainY: noteDims.sustainY, 292 | velocity: note.velocity, 293 | noteId: note.id 294 | } 295 | } 296 | 297 | drawBPM(playerState) { 298 | this.ctx.font = "20px Arial black" 299 | this.ctx.fillStyle = "rgba(255,255,255,0.8)" 300 | this.ctx.textBaseline = "top" 301 | this.ctx.fillText( 302 | Math.round(playerState.bpm) + " BPM", 303 | 20, 304 | this.renderDimensions.menuHeight + PROGRESS_BAR_CANVAS_HEIGHT + 12 305 | ) 306 | } 307 | 308 | addStartingOverlayMessage() { 309 | this.overlayRender.addOverlay("MIDiano", 150) 310 | this.overlayRender.addOverlay("A Javascript MIDI-Player", 150) 311 | this.overlayRender.addOverlay( 312 | "Example song by Bernd Krueger from piano-midi.de", 313 | 150 314 | ) 315 | } 316 | 317 | /** 318 | * 319 | */ 320 | setupCanvases() { 321 | DomHelper.setCanvasSize( 322 | this.getBgCanvas(), 323 | this.renderDimensions.windowWidth, 324 | this.renderDimensions.windowHeight 325 | ) 326 | 327 | DomHelper.setCanvasSize( 328 | this.getMainCanvas(), 329 | this.renderDimensions.windowWidth, 330 | this.renderDimensions.windowHeight 331 | ) 332 | 333 | DomHelper.setCanvasSize( 334 | this.getProgressBarCanvas(), 335 | this.renderDimensions.windowWidth, 336 | 20 337 | ) 338 | 339 | DomHelper.setCanvasSize( 340 | this.getForegroundCanvas(), 341 | this.renderDimensions.windowWidth, 342 | this.renderDimensions.windowHeight 343 | ) 344 | this.setCtxBlur() 345 | } 346 | getBgCanvas() { 347 | if (!this.cnvBG) { 348 | this.cnvBG = DomHelper.createCanvas( 349 | this.renderDimensions.windowWidth, 350 | this.renderDimensions.windowHeight, 351 | { 352 | backgroundColor: "black", 353 | position: "absolute", 354 | top: "0px", 355 | left: "0px" 356 | } 357 | ) 358 | document.body.appendChild(this.cnvBG) 359 | this.ctxBG = this.cnvBG.getContext("2d") 360 | } 361 | return this.cnvBG 362 | } 363 | getMainCanvas() { 364 | if (!this.cnv) { 365 | this.cnv = DomHelper.createCanvas( 366 | this.renderDimensions.windowWidth, 367 | this.renderDimensions.windowHeight, 368 | { 369 | position: "absolute", 370 | top: "0px", 371 | left: "0px" 372 | } 373 | ) 374 | document.body.appendChild(this.cnv) 375 | this.ctx = this.cnv.getContext("2d") 376 | } 377 | return this.cnv 378 | } 379 | getForegroundCanvas() { 380 | if (!this.cnvForeground) { 381 | this.cnvForeground = DomHelper.createCanvas( 382 | this.renderDimensions.windowWidth, 383 | this.renderDimensions.windowHeight, 384 | { 385 | position: "absolute", 386 | top: "0px", 387 | left: "0px" 388 | } 389 | ) 390 | this.cnvForeground.style.pointerEvents = "none" 391 | this.cnvForeground.style.zIndex = "101" 392 | document.body.appendChild(this.cnvForeground) 393 | this.ctxForeground = this.cnvForeground.getContext("2d") 394 | } 395 | return this.cnvForeground 396 | } 397 | 398 | getProgressBarCanvas() { 399 | if (!this.progressBarCanvas) { 400 | this.progressBarCanvas = DomHelper.createCanvas( 401 | this.renderDimensions.windowWidth, 402 | PROGRESS_BAR_CANVAS_HEIGHT, 403 | {} 404 | ) 405 | this.progressBarCanvas.id = "progressBarCanvas" 406 | document.body.appendChild(this.progressBarCanvas) 407 | this.progressBarCtx = this.progressBarCanvas.getContext("2d") 408 | } 409 | return this.progressBarCanvas 410 | } 411 | 412 | isNoteDrawn(note, tracks) { 413 | return !tracks[note.track] || !tracks[note.track].draw 414 | } 415 | 416 | isOnMainCanvas(position) { 417 | return ( 418 | (position.x > this.renderDimensions.menuHeight && 419 | position.y < this.renderDimensions.getAbsolutePianoPosition()) || 420 | position.y > 421 | this.renderDimensions.getAbsolutePianoPosition() + 422 | this.renderDimensions.whiteKeyHeight 423 | ) 424 | } 425 | setMouseCoords(x, y) { 426 | this.mouseX = x 427 | this.mouseY = y 428 | } 429 | getTimeFromHeight(height) { 430 | return ( 431 | (height * this.renderDimensions.getNoteToHeightConst()) / 432 | (this.renderDimensions.windowHeight - 433 | this.renderDimensions.whiteKeyHeight) / 434 | 1000 435 | ) 436 | } 437 | onMenuHeightChanged(menuHeight) { 438 | this.renderDimensions.menuHeight = menuHeight 439 | this.pianoRender.repositionCanvases() 440 | this.getProgressBarCanvas().style.top = menuHeight + "px" 441 | this.noteRender.setMenuHeight(menuHeight) 442 | } 443 | } 444 | -------------------------------------------------------------------------------- /js/Rendering/RenderDimensions.js: -------------------------------------------------------------------------------- 1 | import { isBlack } from "../Util.js" 2 | import { getSetting, setSettingCallback } from "../settings/Settings.js" 3 | 4 | const MAX_NOTE_NUMBER = 87 5 | const MIN_NOTE_NUMBER = 0 6 | 7 | const MIN_WIDTH = 1040 8 | const MIN_HEIGHT = 560 9 | 10 | /** 11 | * Class to handle all the calculation of dimensions of the Notes & Keys on Screen- 12 | */ 13 | export class RenderDimensions { 14 | constructor() { 15 | window.addEventListener("resize", this.resize.bind(this)) 16 | this.resizeCallbacks = [] 17 | this.numberOfWhiteKeysShown = 52 18 | this.minNoteNumber = MIN_NOTE_NUMBER 19 | this.maxNoteNumber = MAX_NOTE_NUMBER 20 | this.menuHeight = 200 21 | setSettingCallback("blackKeyHeight", this.resize.bind(this)) 22 | setSettingCallback("whiteKeyHeight", this.resize.bind(this)) 23 | this.resize() 24 | } 25 | /** 26 | * Recompute all dimensions dependent on Screen Size 27 | */ 28 | resize() { 29 | this.windowWidth = Math.max(MIN_WIDTH, Math.floor(window.innerWidth)) 30 | this.windowHeight = Math.floor(window.innerHeight) 31 | 32 | this.keyDimensions = {} 33 | this.computeKeyDimensions() 34 | this.resizeCallbacks.forEach(func => func()) 35 | } 36 | registerResizeCallback(callback) { 37 | this.resizeCallbacks.push(callback) 38 | } 39 | 40 | /** 41 | * Computes the key dimensions. Should be called on resize. 42 | */ 43 | computeKeyDimensions() { 44 | this.pianoPositionY = getSetting("pianoPosition") 45 | this.whiteKeyWidth = 46 | // Math.max( 47 | // 20, 48 | this.windowWidth / this.numberOfWhiteKeysShown 49 | // ) 50 | 51 | this.whiteKeyHeight = Math.min( 52 | Math.floor(this.windowHeight * 0.2), 53 | this.whiteKeyWidth * 4.5 54 | ) 55 | this.blackKeyWidth = Math.floor(this.whiteKeyWidth * 0.5829787234) 56 | this.blackKeyHeight = 57 | Math.floor((this.whiteKeyHeight * 2) / 3) * 58 | (getSetting("blackKeyHeight") / 100) 59 | 60 | //Do this after computing blackKey, as its dependent on the white key size ( without adjusting for the setting ) 61 | this.whiteKeyHeight *= getSetting("whiteKeyHeight") / 100 62 | } 63 | 64 | /** 65 | * Returns the dimensions for the piano-key of the given note 66 | * 67 | * @param {Number} noteNumber 68 | */ 69 | getKeyDimensions(noteNumber) { 70 | if (!this.keyDimensions.hasOwnProperty(noteNumber)) { 71 | let isNoteBlack = isBlack(noteNumber) 72 | let x = this.getKeyX(noteNumber) 73 | 74 | this.keyDimensions[noteNumber] = { 75 | x: x, 76 | y: 0, 77 | w: isNoteBlack ? this.blackKeyWidth : this.whiteKeyWidth, 78 | h: isNoteBlack ? this.blackKeyHeight : this.whiteKeyHeight, 79 | black: isNoteBlack 80 | } 81 | } 82 | return this.keyDimensions[noteNumber] 83 | } 84 | getAbsolutePianoPosition() { 85 | let pianoSettingsRatio = getSetting("reverseNoteDirection") 86 | ? 1 - parseInt(this.pianoPositionY) / 100 87 | : parseInt(this.pianoPositionY) / 100 88 | let y = 89 | this.windowHeight - 90 | this.whiteKeyHeight - 91 | Math.ceil( 92 | pianoSettingsRatio * 93 | (this.windowHeight - this.whiteKeyHeight - this.menuHeight - 24) 94 | ) 95 | 96 | return y 97 | } 98 | 99 | /** 100 | * Returns x-value of the given Notenumber 101 | * 102 | * @param {Integer} noteNumber 103 | */ 104 | getKeyX(noteNumber) { 105 | return ( 106 | (this.getWhiteKeyNumber(noteNumber) - 107 | this.getWhiteKeyNumber(this.minNoteNumber)) * 108 | this.whiteKeyWidth + 109 | (this.whiteKeyWidth - this.blackKeyWidth / 2) * isBlack(noteNumber) 110 | ) 111 | } 112 | 113 | /** 114 | * Returns the "white key index" of the note number. Ignores if the key itself is black 115 | * @param {Number} noteNumber 116 | */ 117 | getWhiteKeyNumber(noteNumber) { 118 | return ( 119 | noteNumber - 120 | Math.floor(Math.max(0, noteNumber + 11) / 12) - 121 | Math.floor(Math.max(0, noteNumber + 8) / 12) - 122 | Math.floor(Math.max(0, noteNumber + 6) / 12) - 123 | Math.floor(Math.max(0, noteNumber + 3) / 12) - 124 | Math.floor(Math.max(0, noteNumber + 1) / 12) 125 | ) 126 | } 127 | 128 | /** 129 | * Returns y value corresponding to the given time 130 | * 131 | * @param {Number} time 132 | */ 133 | getYForTime(time) { 134 | const height = this.windowHeight - this.whiteKeyHeight 135 | let noteToHeightConst = this.getNoteToHeightConst() 136 | if (time < 0) { 137 | noteToHeightConst /= getSetting("playedNoteFalloffSpeed") 138 | } 139 | 140 | if (getSetting("reverseNoteDirection")) { 141 | return ( 142 | (time / noteToHeightConst) * height + 143 | this.getAbsolutePianoPosition() + 144 | this.whiteKeyHeight 145 | ) 146 | } else { 147 | return ( 148 | height - 149 | (time / noteToHeightConst) * height - 150 | (height - this.getAbsolutePianoPosition()) 151 | ) 152 | } 153 | } 154 | 155 | /** 156 | *Returns rendering x/y-location & size for the given note & time-info 157 | 158 | * @param {Integer} noteNumber 159 | * @param {Number} currentTime 160 | * @param {Number} noteStartTime 161 | * @param {Number} noteEndTime 162 | * @param {Number} sustainOffTime 163 | */ 164 | getNoteDimensions( 165 | noteNumber, 166 | currentTime, 167 | noteStartTime, 168 | noteEndTime, 169 | sustainOffTime 170 | ) { 171 | const dur = noteEndTime - noteStartTime 172 | const isKeyBlack = isBlack(noteNumber) 173 | let x = this.getKeyX(noteNumber) 174 | let w = isKeyBlack ? this.blackKeyWidth : this.whiteKeyWidth 175 | let h = 176 | (dur / this.getNoteToHeightConst()) * 177 | (this.windowHeight - this.whiteKeyHeight) 178 | 179 | let hCorrection = 0 180 | let minNoteHeight = parseFloat(getSetting("minNoteHeight")) 181 | if (h < minNoteHeight + 2) { 182 | hCorrection = minNoteHeight + 2 - h 183 | h = minNoteHeight + 2 184 | } 185 | 186 | let rad = (getSetting("noteBorderRadius") / 100) * w 187 | if (h < rad * 2) { 188 | rad = h / 2 189 | } 190 | let y = this.getYForTime(noteEndTime - currentTime) 191 | let reversed = getSetting("reverseNoteDirection") 192 | if (reversed) { 193 | y -= h 194 | } 195 | 196 | let sustainY = 0 197 | let sustainH = 0 198 | if (sustainOffTime > noteEndTime) { 199 | sustainH = 200 | ((sustainOffTime - noteEndTime) / this.getNoteToHeightConst()) * 201 | (this.windowHeight - this.whiteKeyHeight) 202 | sustainY = this.getYForTime(sustainOffTime - currentTime) 203 | if (reversed) { 204 | sustainY -= sustainH 205 | } 206 | } 207 | 208 | //adjust height/y for notes that have passed the piano / have been played 209 | let showSustainedNotes = getSetting("showSustainedNotes") 210 | let endTime = showSustainedNotes 211 | ? Math.max(isNaN(sustainOffTime) ? 0 : sustainOffTime, noteEndTime) 212 | : noteEndTime 213 | 214 | if (showSustainedNotes) { 215 | if (!isNaN(sustainOffTime) && sustainOffTime < currentTime) { 216 | sustainY += (reversed ? -1 : 1) * this.whiteKeyHeight 217 | } 218 | if ( 219 | !isNaN(sustainOffTime) && 220 | sustainOffTime > currentTime && 221 | noteEndTime < currentTime 222 | ) { 223 | sustainH += this.whiteKeyHeight 224 | if (reversed) { 225 | sustainY -= this.whiteKeyHeight 226 | } 227 | } 228 | } 229 | 230 | if (endTime < currentTime) { 231 | let endRatio = 232 | (currentTime - endTime) / this.getMilisecondsDisplayedAfter() 233 | 234 | endRatio = Math.max(0, 1 - getSetting("noteEndedShrink") * endRatio) 235 | 236 | x = x + (w - w * endRatio) / 2 237 | w *= endRatio 238 | 239 | let tmpH = h 240 | h *= endRatio 241 | y += (reversed ? -1 : 1) * (tmpH - h) 242 | 243 | let tmpSustainH = sustainH 244 | sustainH *= endRatio 245 | sustainY += 246 | (reversed ? -1 : 1) * (tmpSustainH - sustainH) + 247 | (reversed ? -1 : 1) * (tmpH - h) 248 | } 249 | return { 250 | x: x + 1, 251 | y: y + 1 - hCorrection, 252 | w: w - 2, 253 | h: h - 2, 254 | rad: rad, 255 | sustainH: sustainH, 256 | sustainY: sustainY, 257 | isBlack: isKeyBlack 258 | } 259 | } 260 | 261 | getNoteToHeightConst() { 262 | return getSetting("noteToHeightConst") * this.windowHeight 263 | } 264 | 265 | getSecondsDisplayedBefore() { 266 | let pianoPos = getSetting("pianoPosition") / 100 267 | return Math.ceil(((1 - pianoPos) * this.getNoteToHeightConst()) / 1000) 268 | } 269 | getSecondsDisplayedAfter() { 270 | return Math.ceil(this.getMilisecondsDisplayedAfter() / 1000) 271 | } 272 | getMilisecondsDisplayedAfter() { 273 | let pianoPos = getSetting("pianoPosition") / 100 274 | return ( 275 | pianoPos * 276 | (this.getNoteToHeightConst() / getSetting("playedNoteFalloffSpeed")) 277 | ) 278 | } 279 | 280 | //ZOOM 281 | showAll() { 282 | this.setZoom(MIN_NOTE_NUMBER, MAX_NOTE_NUMBER) 283 | } 284 | fitSong(range) { 285 | range.min = Math.max(range.min, MIN_NOTE_NUMBER) 286 | range.max = Math.min(range.max, MAX_NOTE_NUMBER) 287 | while ( 288 | isBlack(range.min - MIN_NOTE_NUMBER) && 289 | range.min > MIN_NOTE_NUMBER 290 | ) { 291 | range.min-- 292 | } 293 | while ( 294 | isBlack(range.max - MIN_NOTE_NUMBER) && 295 | range.max < MAX_NOTE_NUMBER 296 | ) { 297 | range.max++ 298 | } 299 | this.setZoom(range.min, range.max) 300 | } 301 | zoomIn() { 302 | this.minNoteNumber++ 303 | this.maxNoteNumber-- 304 | while ( 305 | isBlack(this.minNoteNumber - MIN_NOTE_NUMBER) && 306 | this.minNoteNumber < this.maxNoteNumber 307 | ) { 308 | this.minNoteNumber++ 309 | } 310 | while ( 311 | isBlack(this.maxNoteNumber - MIN_NOTE_NUMBER) && 312 | this.maxNoteNumber > this.minNoteNumber 313 | ) { 314 | this.maxNoteNumber-- 315 | } 316 | this.setZoom(this.minNoteNumber, this.maxNoteNumber) 317 | } 318 | zoomOut() { 319 | this.minNoteNumber-- 320 | this.maxNoteNumber++ 321 | while ( 322 | isBlack(this.minNoteNumber - MIN_NOTE_NUMBER) && 323 | this.minNoteNumber > MIN_NOTE_NUMBER 324 | ) { 325 | this.minNoteNumber-- 326 | } 327 | while ( 328 | isBlack(this.maxNoteNumber - MIN_NOTE_NUMBER) && 329 | this.maxNoteNumber < MAX_NOTE_NUMBER 330 | ) { 331 | this.maxNoteNumber++ 332 | } 333 | this.setZoom( 334 | Math.max(MIN_NOTE_NUMBER, this.minNoteNumber), 335 | Math.min(MAX_NOTE_NUMBER, this.maxNoteNumber) 336 | ) 337 | } 338 | moveViewLeft() { 339 | if (this.minNoteNumber == MIN_NOTE_NUMBER) return 340 | this.minNoteNumber-- 341 | this.maxNoteNumber-- 342 | while ( 343 | isBlack(this.minNoteNumber - MIN_NOTE_NUMBER) && 344 | this.minNoteNumber > MIN_NOTE_NUMBER 345 | ) { 346 | this.minNoteNumber-- 347 | } 348 | while (isBlack(this.maxNoteNumber - MIN_NOTE_NUMBER)) { 349 | this.maxNoteNumber-- 350 | } 351 | this.setZoom( 352 | Math.max(MIN_NOTE_NUMBER, this.minNoteNumber), 353 | this.maxNoteNumber 354 | ) 355 | } 356 | moveViewRight() { 357 | if (this.maxNoteNumber == MAX_NOTE_NUMBER) return 358 | this.minNoteNumber++ 359 | this.maxNoteNumber++ 360 | while (isBlack(this.minNoteNumber - MIN_NOTE_NUMBER)) { 361 | this.minNoteNumber++ 362 | } 363 | while ( 364 | isBlack(this.maxNoteNumber - MIN_NOTE_NUMBER) && 365 | this.maxNoteNumber < MAX_NOTE_NUMBER 366 | ) { 367 | this.maxNoteNumber++ 368 | } 369 | 370 | this.setZoom( 371 | this.minNoteNumber, 372 | Math.min(MAX_NOTE_NUMBER, this.maxNoteNumber) 373 | ) 374 | } 375 | 376 | /** 377 | * 378 | * @param {Number} minNoteNumber 379 | * @param {Number} maxNoteNumber 380 | */ 381 | setZoom(minNoteNumber, maxNoteNumber) { 382 | let numOfWhiteKeysInRange = 0 383 | for (let i = minNoteNumber; i <= maxNoteNumber; i++) { 384 | numOfWhiteKeysInRange += isBlack(i - MIN_NOTE_NUMBER) ? 0 : 1 385 | } 386 | this.minNoteNumber = minNoteNumber 387 | this.maxNoteNumber = maxNoteNumber 388 | this.numberOfWhiteKeysShown = numOfWhiteKeysInRange 389 | 390 | this.resize() 391 | } 392 | } 393 | -------------------------------------------------------------------------------- /js/Rendering/RenderUtil.js: -------------------------------------------------------------------------------- 1 | /** 2 | * true if the piano-key corresponding to noteNumber is a black key 3 | * @param {Number} noteNumber 4 | */ 5 | export function isBlack(noteNumber) { 6 | return (noteNumber + 11) % 12 == 0 || 7 | (noteNumber + 8) % 12 == 0 || 8 | (noteNumber + 6) % 12 == 0 || 9 | (noteNumber + 3) % 12 == 0 || 10 | (noteNumber + 1) % 12 == 0 11 | ? 1 12 | : 0 13 | } 14 | -------------------------------------------------------------------------------- /js/Rendering/Sequencer.js: -------------------------------------------------------------------------------- 1 | class Sequencer { 2 | constructor() { 3 | this.noteSequence = [] 4 | } 5 | update() { 6 | if (this.scrolling != 0) { 7 | window.setTimeout(this.play.bind(this), 20) 8 | return 9 | } 10 | 11 | let delta = (this.context.currentTime - this.lastTime) * this.playbackSpeed 12 | this.progress += delta 13 | this.lastTime = this.context.currentTime 14 | 15 | let currentTime = this.getTime() 16 | 17 | if (this.isSongEnded(currentTime)) { 18 | this.pause() 19 | return 20 | } 21 | 22 | while (this.isNextNoteReached(currentTime)) { 23 | this.playNote(this.noteSequence.shift()) 24 | } 25 | 26 | this.setChannelVolumes(currentTime) 27 | 28 | if (!this.paused) { 29 | window.requestAnimationFrame(this.play.bind(this)) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /js/Rendering/SustainRenderer.js: -------------------------------------------------------------------------------- 1 | import { getSetting } from "../settings/Settings.js" 2 | 3 | /** 4 | * Class to render the sustain events in the midi-song. Can fill the sustain periods or draw lines for the individual control-events. 5 | */ 6 | export class SustainRender { 7 | constructor(ctx, renderDimensions, lookBackTime, lookAheadTime) { 8 | this.ctx = ctx 9 | this.renderDimensions = renderDimensions 10 | 11 | this.sustainPeriodFillStyle = "rgba(0,0,0,0.4)" 12 | this.sustainOnStrokeStyle = "rgba(55,155,55,0.6)" 13 | this.sustainOffStrokeStyle = "rgba(155,55,55,0.6)" 14 | this.sustainOnOffFont = "12px Arial black" 15 | } 16 | render(time, sustainsBySecond, sustainPeriods) { 17 | if (getSetting("showSustainOnOffs")) { 18 | this.renderSustainOnOffs(time, sustainsBySecond) 19 | } 20 | if (getSetting("showSustainPeriods")) { 21 | this.renderSustainPeriods(time, sustainPeriods) 22 | } 23 | } 24 | /** 25 | * Renders On/Off Sustain Control-Events as lines on screen. 26 | * 27 | * @param {Number} time 28 | * @param {Object} sustainsBySecond 29 | */ 30 | renderSustainOnOffs(time, sustainsBySecond) { 31 | let lookBackTime = Math.floor( 32 | time - this.renderDimensions.getSecondsDisplayedAfter() - 4 33 | ) 34 | let lookAheadTime = Math.ceil( 35 | time + this.renderDimensions.getSecondsDisplayedBefore() + 1 36 | ) 37 | 38 | for ( 39 | let lookUpTime = lookBackTime; 40 | lookUpTime < lookAheadTime; 41 | lookUpTime++ 42 | ) { 43 | if (sustainsBySecond.hasOwnProperty(lookUpTime)) { 44 | sustainsBySecond[lookUpTime].forEach(sustain => { 45 | this.ctx.lineWidth = "1" 46 | let text = "" 47 | this.ctx.fillStyle = "rgba(0,0,0,0.8)" 48 | if (sustain.isOn) { 49 | this.ctx.strokeStyle = this.sustainOnStrokeStyle 50 | text = "Sustain On" 51 | } else { 52 | this.ctx.strokeStyle = this.sustainOffStrokeStyle 53 | text = "Sustain Off" 54 | } 55 | let y = this.renderDimensions.getYForTime( 56 | sustain.timestamp - time * 1000 57 | ) 58 | this.ctx.beginPath() 59 | this.ctx.moveTo(0, y) 60 | this.ctx.lineTo(this.renderDimensions.windowWidth, y) 61 | this.ctx.closePath() 62 | this.ctx.stroke() 63 | 64 | this.ctx.fillStyle = "rgba(200,200,200,0.9)" 65 | this.ctx.font = this.sustainOnOffFont 66 | this.ctx.fillText(text, 10, y - 12) 67 | }) 68 | } 69 | } 70 | } 71 | /** 72 | * Renders Sustain Periods as rectangles on screen. 73 | * @param {Number} time 74 | * @param {Array} sustainPeriods 75 | */ 76 | renderSustainPeriods(time, sustainPeriods) { 77 | let firstSecondShown = Math.floor( 78 | time - this.renderDimensions.getSecondsDisplayedAfter() - 4 79 | ) 80 | let lastSecondShown = Math.ceil( 81 | time + this.renderDimensions.getSecondsDisplayedBefore() + 1 82 | ) 83 | this.ctx.fillStyle = this.sustainPeriodFillStyle 84 | 85 | sustainPeriods 86 | .filter( 87 | period => 88 | (period.start < lastSecondShown * 1000 && 89 | period.start > firstSecondShown * 1000) || 90 | (period.start < firstSecondShown * 1000 && 91 | period.end > firstSecondShown * 1000) 92 | ) 93 | .forEach(period => { 94 | let yStart = this.renderDimensions.getYForTime( 95 | period.start - time * 1000 96 | ) 97 | let yEnd = this.renderDimensions.getYForTime(period.end - time * 1000) 98 | 99 | this.ctx.fillRect( 100 | 0, 101 | yEnd, 102 | this.renderDimensions.windowWidth, 103 | yStart - yEnd 104 | ) 105 | }) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /js/Song.js: -------------------------------------------------------------------------------- 1 | import { CONST } from "./data/CONST.js" 2 | export class Song { 3 | constructor(midiData, fileName, name) { 4 | this.fileName = fileName 5 | this.name = name || fileName 6 | this.text = [] 7 | this.timeSignature 8 | this.keySignarture 9 | this.duration = 0 10 | this.speed = 1 11 | this.notesBySeconds = {} 12 | this.controlEvents = [] 13 | this.temporalData = midiData.temporalData 14 | this.sustainsBySecond = midiData.temporalData.sustainsBySecond 15 | 16 | this.header = midiData.header 17 | this.tracks = midiData.tracks 18 | this.markers = [] 19 | this.otherTracks = [] 20 | this.activeTracks = [] 21 | this.microSecondsPerBeat = 10 22 | this.channels = this.getDefaultChannels() 23 | this.idCounter = 0 24 | 25 | this.processEvents(midiData) 26 | console.log(this) 27 | } 28 | getStart() { 29 | return this.getNoteSequence()[0].timestamp 30 | } 31 | getEnd() { 32 | if (!this.end) { 33 | let noteSequence = this.getNoteSequence().sort( 34 | (a, b) => a.offTime - b.offTime 35 | ) 36 | let lastNote = noteSequence[noteSequence.length - 1] 37 | this.end = lastNote.offTime 38 | } 39 | return this.end 40 | } 41 | getOffset() { 42 | if (!this.smpteOffset) { 43 | return 0 // 44 | } else { 45 | return ( 46 | ((this.smpteOffset.hour * 60 + this.smpteOffset.min) * 60 + 47 | this.smpteOffset.sec) * 48 | 1000 49 | ) 50 | } 51 | } 52 | getMeasureLines() { 53 | if (!this.measureLines) { 54 | this.setMeasureLines() 55 | } 56 | return this.measureLines 57 | } 58 | setMeasureLines() { 59 | let timeSignature = this.timeSignature || { 60 | numerator: 4, 61 | denominator: 4, 62 | thirtySeconds: 8 63 | } 64 | let numerator = timeSignature.numerator || 4 65 | let denominator = timeSignature.denominator || 4 66 | let thirtySeconds = timeSignature.thirtyseconds || 8 67 | 68 | let beatsPerMeasure = numerator / (denominator * (thirtySeconds / 32)) 69 | let skippedBeats = beatsPerMeasure - 1 70 | this.measureLines = {} 71 | Object.keys(this.temporalData.beatsBySecond).forEach(second => { 72 | this.temporalData.beatsBySecond[second].forEach(beat => { 73 | if (skippedBeats < beatsPerMeasure - 1) { 74 | skippedBeats++ 75 | return 76 | } 77 | skippedBeats = 0 78 | if (!this.measureLines.hasOwnProperty(second)) { 79 | this.measureLines[second] = [] 80 | } 81 | this.measureLines[second].push(beat) 82 | }) 83 | }) 84 | } 85 | setSustainPeriods() { 86 | this.sustainPeriods = [] 87 | let isOn = false 88 | for (let second in this.sustainsBySecond) { 89 | this.sustainsBySecond[second].forEach(sustain => { 90 | if (isOn) { 91 | if (!sustain.isOn) { 92 | isOn = false 93 | this.sustainPeriods[this.sustainPeriods.length - 1].end = 94 | sustain.timestamp 95 | } 96 | } else { 97 | if (sustain.isOn) { 98 | isOn = true 99 | this.sustainPeriods.push({ 100 | start: sustain.timestamp, 101 | value: sustain.value 102 | }) 103 | } 104 | } 105 | }) 106 | } 107 | } 108 | getMicrosecondsPerBeat() { 109 | return this.microSecondsPerBeat 110 | } 111 | getBPM(time) { 112 | for (let i = this.temporalData.bpms.length - 1; i >= 0; i--) { 113 | if (this.temporalData.bpms[i].timestamp < time) { 114 | return this.temporalData.bpms[i].bpm 115 | } 116 | } 117 | } 118 | 119 | getNotes(from, to) { 120 | let secondStart = Math.floor(from) 121 | let secondEnd = Math.floor(to) 122 | let notes = [] 123 | for (let i = secondStart; i < secondEnd; i++) { 124 | for (let track in this.activeTracks) { 125 | if (this.activeTracks[track].notesBySeconds.hasOwnProperty(i)) { 126 | for (let n in this.activeTracks[track].notesBySeconds[i]) { 127 | let note = this.activeTracks[track].notesBySeconds[i][n] 128 | if (note.timestamp > from) { 129 | notes.push(note) 130 | } 131 | } 132 | } 133 | } 134 | } 135 | return notes 136 | } 137 | getAllInstruments() { 138 | let instruments = {} 139 | let programs = {} 140 | this.controlEvents = {} 141 | this.tracks.forEach(track => { 142 | track.forEach(event => { 143 | let channel = event.channel 144 | 145 | if (event.type == "programChange") { 146 | programs[channel] = event.programNumber 147 | } 148 | 149 | if (event.type == "controller" && event.controllerType == 7) { 150 | if ( 151 | !this.controlEvents.hasOwnProperty( 152 | Math.floor(event.timestamp / 1000) 153 | ) 154 | ) { 155 | this.controlEvents[Math.floor(event.timestamp / 1000)] = [] 156 | } 157 | this.controlEvents[Math.floor(event.timestamp / 1000)].push(event) 158 | } 159 | 160 | if (event.type == "noteOn") { 161 | if (channel != 9) { 162 | let program = programs[channel] 163 | let instrument = 164 | CONST.INSTRUMENTS.BY_ID[isFinite(program) ? program : channel] 165 | instruments[instrument.id] = true 166 | event.instrument = instrument.id 167 | } else { 168 | instruments["percussion"] = true 169 | event.instrument = "percussion" 170 | } 171 | } 172 | }) 173 | }) 174 | return Object.keys(instruments) 175 | } 176 | processEvents(midiData) { 177 | this.setSustainPeriods() 178 | midiData.tracks.forEach(midiTrack => { 179 | let newTrack = { 180 | notes: [], 181 | meta: [], 182 | tempoChanges: [] 183 | } 184 | 185 | this.distributeEvents(midiTrack, newTrack) 186 | 187 | if (newTrack.notes.length) { 188 | this.activeTracks.push(newTrack) 189 | } else { 190 | this.otherTracks.push(newTrack) 191 | } 192 | }) 193 | 194 | this.activeTracks.forEach((track, trackIndex) => { 195 | track.notesBySeconds = {} 196 | this.setNoteOffTimestamps(track.notes) 197 | this.setNoteSustainTimestamps(track.notes) 198 | track.notes = track.notes.slice(0).filter(note => note.type == "noteOn") 199 | track.notes.forEach(note => (note.track = trackIndex)) 200 | this.setNotesBySecond(track) 201 | }) 202 | } 203 | distributeEvents(track, newTrack) { 204 | track.forEach(event => { 205 | event.id = this.idCounter++ 206 | if (event.type == "noteOn" || event.type == "noteOff") { 207 | newTrack.notes.push(event) 208 | } else if (event.type == "setTempo") { 209 | newTrack.tempoChanges.push(event) 210 | } else if (event.type == "trackName") { 211 | newTrack.name = event.text 212 | } else if (event.type == "text") { 213 | this.text.push(event.text) 214 | } else if (event.type == "timeSignature") { 215 | this.timeSignature = event 216 | } else if (event.type == "keySignature") { 217 | newTrack.keySignature = event 218 | } else if (event.type == "smpteOffset") { 219 | this.smpteOffset = event 220 | } else if (event.type == "marker") { 221 | this.markers.push(event) 222 | } else { 223 | newTrack.meta.push(event) 224 | } 225 | }) 226 | } 227 | 228 | setNotesBySecond(track) { 229 | track.notes.forEach(note => { 230 | let second = Math.floor(note.timestamp / 1000) 231 | if (track.notesBySeconds.hasOwnProperty(second)) { 232 | track.notesBySeconds[second].push(note) 233 | } else { 234 | track.notesBySeconds[second] = [note] 235 | } 236 | }) 237 | } 238 | getNoteSequence() { 239 | if (!this.notesSequence) { 240 | let tracks = [] 241 | for (let t in this.activeTracks) [tracks.push(this.activeTracks[t].notes)] 242 | 243 | this.noteSequence = [].concat 244 | .apply([], tracks) 245 | .sort((a, b) => a.timestamp - b.timestamp) 246 | } 247 | return this.noteSequence.slice(0) 248 | } 249 | getNoteRange() { 250 | let seq = this.getNoteSequence() 251 | let min = 87 252 | let max = 0 253 | seq.forEach(note => { 254 | if (note.noteNumber > max) { 255 | max = note.noteNumber 256 | } 257 | if (note.noteNumber < min) { 258 | min = note.noteNumber 259 | } 260 | }) 261 | return { min, max } 262 | } 263 | setNoteSustainTimestamps(notes) { 264 | for (let i = 0; i < notes.length; i++) { 265 | let note = notes[i] 266 | let currentSustains = this.sustainPeriods.filter( 267 | period => 268 | (period.start < note.timestamp && period.end > note.timestamp) || 269 | (period.start < note.offTime && period.end > note.offTime) 270 | ) 271 | if (currentSustains.length) { 272 | note.sustainOnTime = currentSustains[0].start 273 | let end = Math.max.apply( 274 | null, 275 | currentSustains.map(sustain => sustain.end) 276 | ) 277 | note.sustainOffTime = end 278 | note.sustainDuration = note.sustainOffTime - note.timestamp 279 | } 280 | } 281 | } 282 | 283 | setNoteOffTimestamps(notes) { 284 | for (let i = 0; i < notes.length; i++) { 285 | let note = notes[i] 286 | if (note.type == "noteOn") { 287 | Song.findOffNote(i, notes.slice(0)) 288 | } 289 | } 290 | } 291 | 292 | static findOffNote(index, notes) { 293 | let onNote = notes[index] 294 | for (let i = index + 1; i < notes.length; i++) { 295 | if ( 296 | notes[i].type == "noteOff" && 297 | onNote.noteNumber == notes[i].noteNumber 298 | ) { 299 | onNote.offTime = notes[i].timestamp 300 | onNote.offVelocity = notes[i].velocity 301 | onNote.duration = onNote.offTime - onNote.timestamp 302 | 303 | break 304 | } 305 | } 306 | } 307 | 308 | getDefaultChannels() { 309 | let channels = {} 310 | for (var i = 0; i <= 15; i++) { 311 | channels[i] = { 312 | instrument: i, 313 | pitchBend: 0, 314 | volume: 127, 315 | volumeControl: 50, 316 | mute: false, 317 | mono: false, 318 | omni: false, 319 | solo: false 320 | } 321 | } 322 | channels[9].instrument = -1 323 | return channels 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /js/SoundfontLoader.js: -------------------------------------------------------------------------------- 1 | import { hasBuffer, setBuffer } from "./audio/Buffers.js" 2 | import { getLoader } from "./ui/Loader.js" 3 | import { replaceAllString, iOS } from "./Util.js" 4 | export class SoundfontLoader { 5 | /** 6 | * 7 | * @param {String} instrument 8 | */ 9 | static async loadInstrument(instrument, soundfontName) { 10 | let baseUrl = "https://gleitz.github.io/midi-js-soundfonts/" 11 | if (instrument == "percussion") { 12 | soundfontName = "FluidR3_GM" 13 | baseUrl = "" 14 | } 15 | let fileType = iOS ? "mp3" : "ogg" 16 | return fetch( 17 | baseUrl + soundfontName + "/" + instrument + "-" + fileType + ".js" 18 | ) 19 | .then(response => { 20 | if (response.ok) { 21 | getLoader().setLoadMessage( 22 | "Loaded " + instrument + " from " + soundfontName + " soundfont." 23 | ) 24 | return response.text() 25 | } 26 | throw Error(response.statusText) 27 | }) 28 | .then(data => { 29 | let scr = document.createElement("script") 30 | scr.language = "javascript" 31 | scr.type = "text/javascript" 32 | let newData = replaceAllString(data, "Soundfont", soundfontName) 33 | scr.text = newData 34 | document.body.appendChild(scr) 35 | }) 36 | .catch(function (error) { 37 | console.error("Error fetching soundfont: \n", error) 38 | }) 39 | } 40 | static async loadInstruments(instruments) { 41 | return await Promise.all( 42 | instruments 43 | .slice(0) 44 | .map(instrument => SoundfontLoader.loadInstrument(instrument)) 45 | ) 46 | } 47 | static async getBuffers(ctx) { 48 | let sortedBuffers = null 49 | await SoundfontLoader.createBuffers(ctx).then( 50 | unsortedBuffers => { 51 | unsortedBuffers.forEach(noteBuffer => { 52 | setBuffer( 53 | noteBuffer.soundfontName, 54 | noteBuffer.instrument, 55 | noteBuffer.noteKey, 56 | noteBuffer.buffer 57 | ) 58 | }) 59 | }, 60 | error => console.error(error) 61 | ) 62 | return sortedBuffers 63 | } 64 | static async createBuffers(ctx) { 65 | let promises = [] 66 | for (let soundfontName in MIDI) { 67 | for (let instrument in MIDI[soundfontName]) { 68 | if (!hasBuffer(soundfontName, instrument)) { 69 | console.log( 70 | "Loaded '" + soundfontName + "' instrument : " + instrument 71 | ) 72 | for (let noteKey in MIDI[soundfontName][instrument]) { 73 | let base64Buffer = SoundfontLoader.getBase64Buffer( 74 | MIDI[soundfontName][instrument][noteKey] 75 | ) 76 | promises.push( 77 | SoundfontLoader.getNoteBuffer( 78 | ctx, 79 | base64Buffer, 80 | soundfontName, 81 | noteKey, 82 | instrument 83 | ) 84 | ) 85 | } 86 | } 87 | } 88 | } 89 | return await Promise.all(promises) 90 | } 91 | static async getNoteBuffer( 92 | ctx, 93 | base64Buffer, 94 | soundfontName, 95 | noteKey, 96 | instrument 97 | ) { 98 | let promise = new Promise((resolve, reject) => { 99 | ctx.decodeAudioData( 100 | base64Buffer, 101 | decodedBuffer => { 102 | resolve({ 103 | buffer: decodedBuffer, 104 | noteKey: noteKey, 105 | instrument: instrument, 106 | soundfontName: soundfontName 107 | }) 108 | }, 109 | error => reject(error) 110 | ) 111 | }) 112 | return await promise 113 | 114 | //ios can't handle the promise based decodeAudioData 115 | // return await ctx 116 | // .decodeAudioData(base64Buffer, function (decodedBuffer) { 117 | // audioBuffer = decodedBuffer 118 | // }) 119 | // .then( 120 | // () => { 121 | // return { 122 | // buffer: audioBuffer, 123 | // noteKey: noteKey, 124 | // instrument: instrument, 125 | // soundfontName: soundfontName 126 | // } 127 | // }, 128 | // e => { 129 | // console.log(e) 130 | // } 131 | // ) 132 | } 133 | static getBase64Buffer(str) { 134 | let base64 = str.split(",")[1] 135 | return Base64Binary.decodeArrayBuffer(base64) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /js/Util.js: -------------------------------------------------------------------------------- 1 | function formatTime(seconds, showMilis) { 2 | seconds = Math.max(seconds, 0) 3 | let date = new Date(seconds * 1000) 4 | let timeStrLength = showMilis ? 11 : 8 5 | try { 6 | let timeStr = date.toISOString().substr(11, timeStrLength) 7 | if (timeStr.substr(0, 2) == "00") { 8 | timeStr = timeStr.substr(3) 9 | } 10 | return timeStr 11 | } catch (e) { 12 | console.error(e) 13 | //ignore this. only seems to happend when messing with breakpoints in devtools 14 | } 15 | } 16 | /** 17 | * Checks whether a note Number corresponds to a black piano key 18 | * @param {Number} noteNumber 19 | */ 20 | function isBlack(noteNumber) { 21 | return (noteNumber + 11) % 12 == 0 || 22 | (noteNumber + 8) % 12 == 0 || 23 | (noteNumber + 6) % 12 == 0 || 24 | (noteNumber + 3) % 12 == 0 || 25 | (noteNumber + 1) % 12 == 0 26 | ? 1 27 | : 0 28 | } 29 | function sum(arr) { 30 | return arr.reduce((previousVal, currentVal) => previousVal + currentVal) 31 | } 32 | 33 | /** 34 | * 35 | * @param {CanvasRenderingContext2D} ctx 36 | * @param {Number} x 37 | * @param {Number} y 38 | * @param {Number} width 39 | * @param {Number} height 40 | * @param {Number} radius 41 | */ 42 | function drawRoundRect(ctx, x, y, width, height, radius, isRounded) { 43 | // radius = radius * 2 < ( Math.min( height, width ) ) ? radius : ( Math.min( height, width ) ) / 2 44 | if (typeof radius === "undefined") { 45 | radius = 0 46 | } 47 | if (typeof radius === "number") { 48 | radius = Math.min(radius, Math.min(width / 2, height / 2)) 49 | radius = { 50 | tl: radius, 51 | tr: radius, 52 | br: radius, 53 | bl: radius 54 | } 55 | } else { 56 | var defaultRadius = { 57 | tl: 0, 58 | tr: 0, 59 | br: 0, 60 | bl: 0 61 | } 62 | for (var side in defaultRadius) { 63 | radius[side] = radius[side] || defaultRadius[side] 64 | } 65 | } 66 | 67 | ctx.beginPath() 68 | if (!isRounded) { 69 | ctx.moveTo(x + radius.tl, y) 70 | ctx.lineTo(x + width - radius.tr, y) 71 | ctx.lineTo(x + width, y + radius.tr) 72 | ctx.lineTo(x + width, y + height - radius.br) 73 | ctx.lineTo(x + width - radius.br, y + height) 74 | ctx.lineTo(x + radius.bl, y + height) 75 | ctx.lineTo(x, y + height - radius.bl) 76 | ctx.lineTo(x, y + radius.tl) 77 | ctx.lineTo(x + radius.tl, y) 78 | } else { 79 | ctx.moveTo(x + radius.tl, y) 80 | ctx.lineTo(x + width - radius.tr, y) 81 | ctx.quadraticCurveTo(x + width, y, x + width, y + radius.tr) 82 | ctx.lineTo(x + width, y + height - radius.br) 83 | ctx.quadraticCurveTo( 84 | x + width, 85 | y + height, 86 | x + width - radius.br, 87 | y + height 88 | ) 89 | ctx.lineTo(x + radius.bl, y + height) 90 | ctx.quadraticCurveTo(x, y + height, x, y + height - radius.bl) 91 | ctx.lineTo(x, y + radius.tl) 92 | ctx.quadraticCurveTo(x, y, x + radius.tl, y) 93 | } 94 | ctx.closePath() 95 | } 96 | 97 | function replaceAllString(text, replaceThis, withThat) { 98 | return text.replace(new RegExp(replaceThis, "g"), withThat) 99 | } 100 | 101 | function groupArrayBy(arr, keyFunc) { 102 | let keys = {} 103 | arr.forEach(el => (keys[keyFunc(el)] = [])) 104 | Object.keys(keys).forEach(key => { 105 | arr.forEach(el => (keyFunc(el) == key ? keys[keyFunc(el)].push(el) : null)) 106 | }) 107 | return keys 108 | } 109 | function loadJson(url, callback) { 110 | let request = new XMLHttpRequest() 111 | request.overrideMimeType("application/json") 112 | request.open("GET", url, true) 113 | request.onreadystatechange = function () { 114 | if (request.readyState == 4 && request.status == "200") { 115 | callback(request.responseText) 116 | } 117 | } 118 | request.send(null) 119 | } 120 | 121 | function iOS() { 122 | return ( 123 | [ 124 | "iPad Simulator", 125 | "iPhone Simulator", 126 | "iPod Simulator", 127 | "iPad", 128 | "iPhone", 129 | "iPod" 130 | ].includes(navigator.platform) || 131 | // iPad on iOS 13 detection 132 | (navigator.userAgent.includes("Mac") && "ontouchend" in document) 133 | ) 134 | } 135 | 136 | export { 137 | formatTime, 138 | isBlack, 139 | sum, 140 | drawRoundRect, 141 | replaceAllString, 142 | groupArrayBy, 143 | loadJson, 144 | iOS 145 | } 146 | -------------------------------------------------------------------------------- /js/audio/AudioNote.js: -------------------------------------------------------------------------------- 1 | import { getSetting } from "../settings/Settings.js" 2 | import { 3 | createCompleteGainNode, 4 | createContinuousGainNode 5 | } from "./GainNodeController.js" 6 | 7 | class AudioNote { 8 | constructor(context, buffer) { 9 | this.source = context.createBufferSource() 10 | this.source.buffer = buffer 11 | } 12 | 13 | connectSource(context, gainNode) { 14 | this.source.connect(gainNode) 15 | this.getGainNode().connect(context.destination) 16 | } 17 | getGainNode() { 18 | return this.gainNodeController.gainNode 19 | } 20 | suspend() { 21 | this.source.stop(0) 22 | } 23 | play(time) { 24 | this.source.start(time) 25 | } 26 | endAt(time, isSustained) { 27 | let endTime = this.gainNodeController.endAt(time, isSustained) 28 | this.endSourceAt(endTime + 0.5) 29 | } 30 | endSourceAt(time) { 31 | this.source.stop(time) 32 | } 33 | } 34 | 35 | export const createContinuousAudioNote = (context, buffer, volume) => { 36 | let audioNote = new AudioNote(context, buffer) 37 | 38 | audioNote.gainNodeController = createContinuousGainNode( 39 | context, 40 | context.currentTime, 41 | volume 42 | ) 43 | 44 | audioNote.connectSource(context, audioNote.gainNodeController.gainNode) 45 | audioNote.play(context.currentTime) 46 | return audioNote 47 | } 48 | 49 | export const createCompleteAudioNote = ( 50 | note, 51 | currentSongTime, 52 | playbackSpeed, 53 | volume, 54 | isPlayalong, 55 | context, 56 | buffer 57 | ) => { 58 | let audioNote = new AudioNote(context, buffer) 59 | const gainValue = getNoteGain(note, volume) 60 | if (gainValue == 0) { 61 | return audioNote 62 | } 63 | 64 | let contextTimes = getContextTimesForNote( 65 | context, 66 | note, 67 | currentSongTime, 68 | playbackSpeed, 69 | isPlayalong 70 | ) 71 | const isSustained = contextTimes.end < contextTimes.sustainOff 72 | 73 | audioNote.gainNodeController = createCompleteGainNode( 74 | context, 75 | gainValue, 76 | contextTimes, 77 | isSustained 78 | ) 79 | 80 | audioNote.connectSource(context, audioNote.getGainNode()) 81 | 82 | audioNote.play(contextTimes.start) 83 | audioNote.endAt( 84 | isSustained ? contextTimes.sustainOff : contextTimes.end, 85 | isSustained 86 | ) 87 | 88 | return audioNote 89 | } 90 | 91 | function getContextTimesForNote( 92 | context, 93 | note, 94 | currentSongTime, 95 | playbackSpeed, 96 | isPlayAlong 97 | ) { 98 | let delayUntilNote = (note.timestamp / 1000 - currentSongTime) / playbackSpeed 99 | let delayCorrection = 0 100 | if (isPlayAlong) { 101 | delayCorrection = getPlayalongDelayCorrection(delayUntilNote) 102 | delayUntilNote = Math.max(0, delayUntilNote) 103 | } 104 | let start = context.currentTime + delayUntilNote 105 | 106 | let end = start + note.duration / 1000 / playbackSpeed + delayCorrection 107 | 108 | let sustainOff = start + note.sustainDuration / 1000 / playbackSpeed 109 | return { start, end, sustainOff } 110 | } 111 | 112 | function getPlayalongDelayCorrection(delayUntilNote) { 113 | let delayCorrection = 0 114 | if (delayUntilNote < 0) { 115 | console.log("negative delay") 116 | delayCorrection = -1 * (delayUntilNote - 0.1) 117 | delayUntilNote = 0.1 118 | } 119 | 120 | return delayCorrection 121 | } 122 | 123 | function getNoteGain(note, volume) { 124 | let gain = 2 * (note.velocity / 127) * volume 125 | 126 | let clampedGain = Math.min(10.0, Math.max(-1.0, gain)) 127 | return clampedGain 128 | } 129 | -------------------------------------------------------------------------------- /js/audio/AudioPlayer.js: -------------------------------------------------------------------------------- 1 | import { getSetting } from "../settings/Settings.js" 2 | import { SoundfontLoader } from "../SoundfontLoader.js" 3 | import { getLoader } from "../ui/Loader.js" 4 | import { 5 | createContinuousAudioNote, 6 | createCompleteAudioNote 7 | } from "./AudioNote.js" 8 | import { getBufferForNote } from "./Buffers.js" 9 | 10 | export class AudioPlayer { 11 | constructor() { 12 | window.AudioContext = window.AudioContext || window.webkitAudioContext 13 | 14 | this.context = new AudioContext() 15 | this.buffers = {} 16 | this.audioNotes = [] 17 | this.soundfontName = "MusyngKite" 18 | 19 | this.loadMetronomeSounds() 20 | } 21 | 22 | getContextTime() { 23 | return this.context.currentTime 24 | } 25 | getContext() { 26 | return this.context 27 | } 28 | isRunning() { 29 | return this.context.state == "running" 30 | } 31 | resume() { 32 | this.context.resume() 33 | } 34 | suspend() { 35 | this.context.suspend() 36 | } 37 | stopAllSources() { 38 | this.audioNotes.forEach(audioNote => { 39 | try { 40 | audioNote.source.stop(0) 41 | } catch (error) { 42 | //Lets ignore this. Happens when notes are created while jumping on timeline 43 | } 44 | }) 45 | } 46 | createContinuousNote(noteNumber, volume, instrument) { 47 | if (this.context.state === "suspended") { 48 | this.wasSuspended = true 49 | this.context.resume() 50 | } 51 | let audioNote = createContinuousAudioNote( 52 | this.context, 53 | getBufferForNote(this.soundfontName, instrument, noteNumber), 54 | volume / 100 55 | ) 56 | 57 | return audioNote 58 | } 59 | noteOffContinuous(audioNote) { 60 | audioNote.endAt(this.context.currentTime + 0.1, false) 61 | } 62 | 63 | playCompleteNote(currentTime, note, playbackSpeed, volume, isPlayAlong) { 64 | const buffer = getBufferForNote( 65 | this.soundfontName, 66 | note.instrument, 67 | note.noteNumber 68 | ) 69 | 70 | let audioNote = createCompleteAudioNote( 71 | note, 72 | currentTime, 73 | playbackSpeed, 74 | volume, 75 | isPlayAlong, 76 | this.context, 77 | buffer 78 | ) 79 | this.audioNotes.push(audioNote) 80 | } 81 | 82 | playBeat(time, newMeasure) { 83 | if (time < 0) return 84 | this.context.resume() 85 | let ctxTime = this.context.currentTime 86 | 87 | const source = this.context.createBufferSource() 88 | const gainNode = this.context.createGain() 89 | gainNode.gain.value = getSetting("metronomeVolume") 90 | source.buffer = newMeasure ? this.metronomSound1 : this.metronomSound2 91 | source.connect(gainNode) 92 | gainNode.connect(this.context.destination) 93 | source.start(ctxTime + time) 94 | source.stop(ctxTime + time + 0.2) 95 | } 96 | 97 | async switchSoundfont(soundfontName, currentSong) { 98 | this.soundfontName = soundfontName 99 | getLoader().setLoadMessage("Loading Instruments") 100 | await this.loadInstrumentsForSong(currentSong) 101 | getLoader().setLoadMessage("Loading Buffers") 102 | return await this.loadBuffers() 103 | } 104 | loadMetronomeSounds() { 105 | let audioPl = this 106 | 107 | const request = new XMLHttpRequest() 108 | request.open("GET", "../../metronome/1.wav") 109 | request.responseType = "arraybuffer" 110 | request.onload = function () { 111 | let undecodedAudio = request.response 112 | audioPl.context.decodeAudioData( 113 | undecodedAudio, 114 | data => (audioPl.metronomSound1 = data) 115 | ) 116 | } 117 | request.send() 118 | 119 | const request2 = new XMLHttpRequest() 120 | request2.open("GET", "../../metronome/2.wav") 121 | request2.responseType = "arraybuffer" 122 | request2.onload = function () { 123 | let undecodedAudio = request2.response 124 | audioPl.context.decodeAudioData( 125 | undecodedAudio, 126 | data => (audioPl.metronomSound2 = data) 127 | ) 128 | } 129 | request2.send() 130 | } 131 | async loadInstrumentsForSong(currentSong) { 132 | if (!this.buffers.hasOwnProperty(this.soundfontName)) { 133 | this.buffers[this.soundfontName] = {} 134 | } 135 | 136 | let instrumentsOfSong = currentSong.getAllInstruments() 137 | 138 | //filter instruments we've loaded already and directly map onto promise 139 | let neededInstruments = instrumentsOfSong 140 | .filter( 141 | instrument => 142 | !this.buffers[this.soundfontName].hasOwnProperty(instrument) 143 | ) 144 | .map(instrument => 145 | SoundfontLoader.loadInstrument(instrument, this.soundfontName) 146 | ) 147 | if (instrumentsOfSong.includes("percussion")) { 148 | neededInstruments.push( 149 | SoundfontLoader.loadInstrument("percussion", "FluidR3_GM") 150 | ) 151 | } 152 | if (neededInstruments.length == 0) { 153 | return Promise.resolve() 154 | } 155 | await Promise.all(neededInstruments) 156 | } 157 | 158 | async loadBuffers() { 159 | return await SoundfontLoader.getBuffers(this.context).then(buffers => { 160 | console.log("Buffers loaded") 161 | this.loading = false 162 | }) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /js/audio/Buffers.js: -------------------------------------------------------------------------------- 1 | import { CONST } from "../data/CONST.js" 2 | 3 | const buffers = {} 4 | export const getBuffers = () => { 5 | return buffers 6 | } 7 | export const getBufferForNote = (soundfontName, instrument, noteNumber) => { 8 | let noteKey = CONST.MIDI_NOTE_TO_KEY[noteNumber + 21] 9 | let buffer 10 | if (instrument == "percussion") { 11 | soundfontName = "FluidR3_GM" 12 | } 13 | try { 14 | buffer = buffers[soundfontName][instrument][noteKey] 15 | } catch (e) { 16 | console.error(e) 17 | } 18 | return buffer 19 | } 20 | export const hasBuffer = (soundfontName, instrument) => 21 | buffers.hasOwnProperty(soundfontName) && 22 | buffers[soundfontName].hasOwnProperty(instrument) 23 | 24 | export const setBuffer = (soundfontName, instrument, noteKey, buffer) => { 25 | if (!buffers.hasOwnProperty(soundfontName)) { 26 | buffers[soundfontName] = {} 27 | } 28 | if (!buffers[soundfontName].hasOwnProperty(instrument)) { 29 | buffers[soundfontName][instrument] = {} 30 | } 31 | buffers[soundfontName][instrument][noteKey] = buffer 32 | } 33 | -------------------------------------------------------------------------------- /js/audio/GainNodeController.js: -------------------------------------------------------------------------------- 1 | import { getSetting } from "../settings/Settings.js" 2 | 3 | const TIME_CONST = 0.05 4 | class GainNodeController { 5 | constructor(context) { 6 | this.createGainNode(context) 7 | } 8 | createGainNode(context) { 9 | this.gainNode = context.createGain() 10 | this.gainNode.value = 0 11 | this.gainNode.gain.setTargetAtTime(0, 0, TIME_CONST) 12 | } 13 | 14 | setAttackAndDecay(start, gainValue, adsrValues) { 15 | let endOfAttackTime = start + adsrValues.attack 16 | 17 | this.sustainValue = gainValue * adsrValues.sustain 18 | this.endOfDecayTime = endOfAttackTime + adsrValues.decay 19 | 20 | //Start at 0 21 | this.gainNode.gain.linearRampToValueAtTime(0, start, TIME_CONST) 22 | 23 | //Attack 24 | this.gainNode.gain.linearRampToValueAtTime( 25 | gainValue, 26 | endOfAttackTime, 27 | TIME_CONST 28 | ) 29 | //Decay 30 | this.gainNode.gain.linearRampToValueAtTime( 31 | this.sustainValue, 32 | this.endOfDecayTime, 33 | TIME_CONST 34 | ) 35 | } 36 | setReleaseGainNode(end, release) { 37 | this.gainNode.gain.linearRampToValueAtTime( 38 | this.sustainValue, 39 | end, 40 | TIME_CONST 41 | ) 42 | //Release 43 | this.gainNode.gain.linearRampToValueAtTime(0.001, end + release) 44 | this.gainNode.gain.linearRampToValueAtTime( 45 | 0, 46 | end + release + 0.001, 47 | TIME_CONST 48 | ) 49 | this.gainNode.gain.setTargetAtTime(0, end + release + 0.005, TIME_CONST) 50 | } 51 | endAt(endTime, isSustained) { 52 | const release = isSustained 53 | ? parseFloat(getSetting("adsrReleasePedal")) 54 | : parseFloat(getSetting("adsrReleaseKey")) 55 | endTime = Math.max(endTime, this.endOfDecayTime) 56 | this.setReleaseGainNode(endTime, release) 57 | return endTime 58 | } 59 | } 60 | 61 | function getAdsrValues() { 62 | let attack = parseFloat(getSetting("adsrAttack")) 63 | let decay = parseFloat(getSetting("adsrDecay")) 64 | let sustain = parseFloat(getSetting("adsrSustain")) / 100 65 | let releasePedal = parseFloat(getSetting("adsrReleasePedal")) 66 | let releaseKey = parseFloat(getSetting("adsrReleaseKey")) 67 | return { attack, decay, sustain, releasePedal, releaseKey } 68 | } 69 | function getAdsrAdjustedForDuration(duration) { 70 | let maxGainLevel = 1 71 | let adsrValues = getAdsrValues() 72 | //If duration is smaller than decay and attack, shorten decay / set it to 0 73 | if (duration < adsrValues.attack + adsrValues.decay) { 74 | adsrValues.decay = Math.max(duration - adsrValues.attack, 0) 75 | } 76 | //if attack alone is longer than duration, linearly lower the maximum gain value that will be reached before 77 | //the sound starts to release. 78 | if (duration < adsrValues.attack) { 79 | let ratio = duration / adsrValues.attack 80 | maxGainLevel *= ratio 81 | adsrValues.attack *= ratio 82 | adsrValues.decay = 0 83 | adsrValues.sustain = 1 84 | } 85 | adsrValues.maxGainLevel = maxGainLevel 86 | return adsrValues 87 | } 88 | 89 | export const createContinuousGainNode = (context, start, gainValue) => { 90 | const gainNodeGen = new GainNodeController(context) 91 | 92 | gainNodeGen.setAttackAndDecay(start, gainValue, getAdsrValues()) 93 | return gainNodeGen 94 | } 95 | 96 | export const createCompleteGainNode = ( 97 | context, 98 | gainValue, 99 | ctxTimes, 100 | isSustained 101 | ) => { 102 | const gainNodeGen = new GainNodeController(context) 103 | 104 | const adsrValues = getAdsrAdjustedForDuration( 105 | (isSustained ? ctxTimes.sustainOff : ctxTimes.end) - ctxTimes.start 106 | ) 107 | 108 | //Adjust gain value if attack period is longer than duration of entire note. 109 | gainValue *= adsrValues.maxGainLevel 110 | 111 | gainNodeGen.setAttackAndDecay(ctxTimes.start, gainValue, adsrValues) 112 | 113 | let end = ctxTimes.end 114 | let release = parseFloat(getSetting("adsrReleaseKey")) 115 | if (isSustained && getSetting("sustainEnabled")) { 116 | end = ctxTimes.sustainOff 117 | release = parseFloat(getSetting("adsrReleasePedal")) 118 | } 119 | 120 | return gainNodeGen 121 | } 122 | -------------------------------------------------------------------------------- /js/data/exampleSongs.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "Liszt - La Campanella", 4 | "fileName": "liz_et3.mid", 5 | "url":"https://bewelge.github.io/piano-midi.de-Files/midi/liz_et3.mid?raw=true" 6 | }, 7 | { 8 | "name" : "Bach - Prelude and Fugue BWV 846", 9 | "fileName":"bach_846.mid", 10 | "url":"https://bewelge.github.io/piano-midi.de-Files/midi/bach_846.mid?raw=true" 11 | } 12 | ] -------------------------------------------------------------------------------- /js/main.js: -------------------------------------------------------------------------------- 1 | import { Render } from "./Rendering/Render.js" 2 | import { UI } from "./ui/UI.js" 3 | import { InputListeners } from "./InputListeners.js" 4 | import { getPlayer, getPlayerState } from "./player/Player.js" 5 | import { loadJson } from "./Util.js" 6 | import { FileLoader } from "./player/FileLoader.js" 7 | 8 | /** 9 | * 10 | * 11 | * TODOs: 12 | * 13 | * UI: 14 | * - Accessability 15 | * - Mobile 16 | * - Load from URL / circumvent CORS.. Extension? 17 | * - channel menu 18 | * - added song info to "loaded songs" 19 | * - fix the minimize button 20 | * - Fix fullscreen on mobile 21 | * 22 | * Audio 23 | * - Figure out how to handle different ADSR envelopes / release times for instruments 24 | * - implement control messages for 25 | * - sostenuto pedal 26 | * - only keys that are pressed while pedal is hit are sustained 27 | * - soft pedal 28 | * - how does that affect sound? 29 | * - implement pitch shift 30 | * - settings for playalong: 31 | * - accuracy needed 32 | * - different modes 33 | * 34 | * MISC 35 | * - add starting songs from piano-midi 36 | * - make instrument choosable for tracks 37 | * - Metronome 38 | * - Update readme - new screenshot, install/ run instructions 39 | * - Choose License 40 | * - 41 | * - 42 | * 43 | * 44 | * 45 | * bugs: 46 | * - Fix iOS 47 | * - too long notes disappear too soon 48 | */ 49 | let ui 50 | let loading 51 | let listeners 52 | 53 | window.onload = async function () { 54 | await init() 55 | loading = true 56 | 57 | // loadSongFromURL("http://www.piano-midi.de/midis/brahms/brahms_opus1_1_format0.mid") 58 | } 59 | 60 | async function init() { 61 | render = new Render() 62 | ui = new UI(render) 63 | listeners = new InputListeners(ui, render) 64 | renderLoop() 65 | 66 | loadStartingSong() 67 | 68 | loadJson("./js/data/exampleSongs.json", json => 69 | ui.setExampleSongs(JSON.parse(json)) 70 | ) 71 | } 72 | 73 | let render 74 | function renderLoop() { 75 | render.render(getPlayerState()) 76 | window.requestAnimationFrame(renderLoop) 77 | } 78 | async function loadStartingSong() { 79 | const domain = window.location.href 80 | let url = "https://midiano.com/mz_331_3.mid?raw=true" // "https://bewelge.github.io/piano-midi.de-Files/midi/alb_esp1.mid?raw=true" // 81 | if (domain.split("github").length > 1) { 82 | url = "https://Bewelge.github.io/MIDIano/mz_331_3.mid?raw=true" 83 | } 84 | 85 | FileLoader.loadSongFromURL(url, (response, fileName) => 86 | getPlayer().loadSong(response, fileName, "Mozart - Turkish March") 87 | ) // Local: "../mz_331_3.mid") 88 | } 89 | -------------------------------------------------------------------------------- /js/player/FileLoader.js: -------------------------------------------------------------------------------- 1 | import { getLoader } from "../ui/Loader.js" 2 | 3 | export class FileLoader { 4 | static async loadSongFromURL(url, callback) { 5 | getLoader().setLoadMessage(`Loading Song from ${url}`) 6 | const response = fetch(url, { 7 | method: "GET" 8 | }).then(response => { 9 | const filename = url 10 | response.blob().then(blob => { 11 | const reader = new FileReader() 12 | reader.onload = function (theFile) { 13 | callback(reader.result, filename, () => {}) 14 | } 15 | reader.readAsDataURL(blob) 16 | }) 17 | }) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /js/player/Tracks.js: -------------------------------------------------------------------------------- 1 | import { CONST } from "../data/CONST.js" 2 | 3 | /** 4 | * 5 | */ 6 | 7 | var theTracks = {} 8 | export const getTracks = () => { 9 | return theTracks 10 | } 11 | export const getTrack = trackId => { 12 | return theTracks[trackId] 13 | } 14 | export const setupTracks = activeTracks => { 15 | theTracks = {} 16 | for (let trackId in activeTracks) { 17 | if (!theTracks.hasOwnProperty(trackId)) { 18 | theTracks[trackId] = { 19 | draw: true, 20 | color: CONST.TRACK_COLORS[trackId % 4], 21 | volume: 100, 22 | name: activeTracks[trackId].name || "Track " + trackId, 23 | requiredToPlay: false, 24 | index: trackId 25 | } 26 | } 27 | theTracks[trackId].color = CONST.TRACK_COLORS[trackId % 4] 28 | } 29 | } 30 | 31 | export const isTrackRequiredToPlay = trackId => { 32 | return theTracks[trackId].requiredToPlay 33 | } 34 | 35 | export const isAnyTrackPlayalong = () => { 36 | return ( 37 | Object.keys(theTracks).filter(trackId => theTracks[trackId].requiredToPlay) 38 | .length > 0 39 | ) 40 | } 41 | 42 | export const getTrackVolume = trackId => { 43 | return theTracks[trackId].volume 44 | } 45 | 46 | export const isTrackDrawn = trackId => { 47 | return theTracks[trackId] && theTracks[trackId].draw 48 | } 49 | 50 | export const getTrackColor = trackId => { 51 | return theTracks[trackId] ? theTracks[trackId].color : "rgba(0,0,0,0)" 52 | } 53 | 54 | export const setTrackColor = (trackId, colorId, color) => { 55 | theTracks[trackId].color[colorId] = color 56 | } 57 | -------------------------------------------------------------------------------- /js/settings/DefaultSettings.js: -------------------------------------------------------------------------------- 1 | import { getSetting, setSetting } from "./Settings.js" 2 | 3 | export const getDefaultSettings = () => { 4 | let copy = {} 5 | for (let tab in defaultSettings) { 6 | copy[tab] = {} 7 | for (let category in defaultSettings[tab]) { 8 | copy[tab][category] = [] 9 | defaultSettings[tab][category].forEach(setting => { 10 | let settingCopy = {} 11 | for (let attribute in setting) { 12 | settingCopy[attribute] = setting[attribute] 13 | } 14 | copy[tab][category].push(settingCopy) 15 | }) 16 | } 17 | } 18 | return copy 19 | } 20 | const TAB_GENERAL = "General" 21 | const TAB_AUDIO = "Audio" 22 | const TAB_VIDEO = "Video" 23 | 24 | const defaultSettings = { 25 | //tabs 26 | General: { 27 | //default or subcategory 28 | default: [ 29 | { 30 | type: "slider", 31 | id: "renderOffset", 32 | label: "Render offset (ms)", 33 | value: 0, 34 | min: -250, 35 | max: 250, 36 | step: 1, 37 | onChange: value => setSetting("renderOffset", value) 38 | }, 39 | { 40 | type: "checkbox", 41 | id: "reverseNoteDirection", 42 | label: "Reverse note direction", 43 | value: false, 44 | onChange: ev => { 45 | setSetting("reverseNoteDirection", ev.target.checked) 46 | setSetting( 47 | "pianoPosition", 48 | Math.abs(parseInt(getSetting("pianoPosition")) + 1) 49 | ) 50 | } 51 | }, 52 | 53 | { 54 | type: "checkbox", 55 | id: "showBPM", 56 | label: "Show BPM", 57 | value: true, 58 | onChange: ev => setSetting("showBPM", ev.target.checked) 59 | }, 60 | { 61 | type: "checkbox", 62 | id: "showMiliseconds", 63 | label: "Show Miliseconds", 64 | value: true, 65 | onChange: ev => setSetting("showMiliseconds", ev.target.checked) 66 | }, 67 | { 68 | type: "checkbox", 69 | id: "showNoteDebugInfo", 70 | label: "Enable debug info on hover over note", 71 | value: false, 72 | onChange: ev => setSetting("showNoteDebugInfo", ev.target.checked) 73 | }, 74 | { 75 | type: "checkbox", 76 | id: "showMarkersSong", 77 | label: "Show markers in the song", 78 | value: false, 79 | onChange: ev => setSetting("showMarkersSong", ev.target.checked) 80 | }, 81 | { 82 | type: "checkbox", 83 | id: "showMarkersTimeline", 84 | label: "Show markers on timeline", 85 | value: false, 86 | onChange: ev => setSetting("showMarkersTimeline", ev.target.checked) 87 | }, 88 | { 89 | type: "checkbox", 90 | id: "showFps", 91 | label: "Show FPS", 92 | value: true, 93 | onChange: ev => setSetting("showFps", ev.target.checked) 94 | }, 95 | { 96 | type: "color", 97 | id: "inputNoteColor", 98 | label: "Your note color", 99 | value: "rgba(40,155,155,0.8)", 100 | onChange: value => setSetting("inputNoteColor", value) 101 | } 102 | ], 103 | "On Screen Piano": [ 104 | { 105 | type: "checkbox", 106 | id: "clickablePiano", 107 | label: "Clickable piano", 108 | value: true, 109 | onChange: ev => setSetting("clickablePiano", ev.target.checked) 110 | }, 111 | { 112 | type: "checkbox", 113 | id: "showKeyNamesOnPianoWhite", 114 | label: "Show white key names on piano", 115 | value: true, 116 | onChange: ev => 117 | setSetting("showKeyNamesOnPianoWhite", ev.target.checked) 118 | }, 119 | { 120 | type: "checkbox", 121 | id: "showKeyNamesOnPianoBlack", 122 | label: "Show black key names on piano", 123 | value: true, 124 | onChange: ev => 125 | setSetting("showKeyNamesOnPianoBlack", ev.target.checked) 126 | }, 127 | { 128 | type: "checkbox", 129 | id: "highlightActivePianoKeys", 130 | label: "Color active piano keys", 131 | value: true, 132 | onChange: ev => setSetting("showPianoKeys", ev.target.checked) 133 | }, 134 | { 135 | type: "checkbox", 136 | id: "drawPianoKeyHitEffect", 137 | label: "Piano Hit key effect", 138 | value: true, 139 | onChange: ev => setSetting("drawPianoKeyHitEffect", ev.target.checked) 140 | }, 141 | { 142 | type: "slider", 143 | id: "pianoPosition", 144 | label: "Piano Position", 145 | value: 20, 146 | min: 0, 147 | max: 100, 148 | step: 1, 149 | onChange: value => setSetting("pianoPosition", value) 150 | }, 151 | { 152 | type: "slider", 153 | id: "whiteKeyHeight", 154 | label: "Height (%) - White keys", 155 | value: 100, 156 | min: 0, 157 | max: 200, 158 | step: 1, 159 | onChange: value => setSetting("whiteKeyHeight", value) 160 | }, 161 | { 162 | type: "slider", 163 | id: "blackKeyHeight", 164 | label: "Height (%) - Black keys", 165 | value: 100, 166 | min: 0, 167 | max: 200, 168 | step: 1, 169 | onChange: value => setSetting("blackKeyHeight", value) 170 | } 171 | ] 172 | }, 173 | 174 | Video: { 175 | default: [ 176 | { 177 | type: "slider", 178 | id: "noteToHeightConst", 179 | label: "Seconds shown on screen", 180 | value: 3, 181 | min: 0.1, 182 | max: 30, 183 | step: 0.1, 184 | onChange: value => setSetting("noteToHeightConst", value) 185 | } 186 | ], 187 | "Note Appearance": [ 188 | { 189 | type: "checkbox", 190 | id: "showHitKeys", 191 | label: "Active Notes effect", 192 | value: true, 193 | onChange: ev => setSetting("showHitKeys", ev.target.checked) 194 | }, 195 | 196 | { 197 | type: "checkbox", 198 | id: "strokeActiveNotes", 199 | label: "Stroke active notes", 200 | value: true, 201 | onChange: ev => setSetting("strokeActiveNotes", ev.target.checked) 202 | }, 203 | { 204 | type: "color", 205 | id: "strokeActiveNotesColor", 206 | label: "Stroke color", 207 | value: "rgba(240,240,240,0.5)", 208 | onChange: value => setSetting("strokeActiveNotesColor", value) 209 | }, 210 | { 211 | type: "slider", 212 | id: "strokeActiveNotesWidth", 213 | label: "Stroke width", 214 | value: "4", 215 | min: 1, 216 | max: 10, 217 | step: 0.5, 218 | onChange: value => setSetting("strokeActiveNotesWidth", value) 219 | }, 220 | { 221 | type: "checkbox", 222 | id: "strokeNotes", 223 | label: "Stroke notes", 224 | value: true, 225 | onChange: ev => setSetting("strokeNotes", ev.target.checked) 226 | }, 227 | { 228 | type: "color", 229 | id: "strokeNotesColor", 230 | label: "Stroke color", 231 | value: "rgba(0,0,0,1)", 232 | onChange: value => setSetting("strokeNotesColor", value) 233 | }, 234 | { 235 | type: "slider", 236 | id: "strokeNotesWidth", 237 | label: "Stroke width", 238 | value: "1", 239 | min: 1, 240 | max: 10, 241 | step: 0.5, 242 | onChange: value => setSetting("strokeNotesWidth", value) 243 | }, 244 | { 245 | type: "checkbox", 246 | id: "roundedNotes", 247 | label: "Rounded notes", 248 | value: true, 249 | onChange: ev => setSetting("roundedNotes", ev.target.checked) 250 | }, 251 | //TODO fix getAlphaFromY in Noterender. 252 | // { 253 | // type: "checkbox", 254 | // id: "fadeInNotes", 255 | // label: "Enable fade in effect", 256 | // value: true, 257 | // onChange: ev => setSetting("fadeInNotes", ev.target.checked) 258 | // }, 259 | { 260 | type: "slider", 261 | id: "noteBorderRadius", 262 | label: "Note border radius (%)", 263 | value: 15, 264 | min: 0, 265 | max: 50, 266 | step: 1, 267 | onChange: value => setSetting("noteBorderRadius", value) 268 | }, 269 | { 270 | type: "slider", 271 | id: "minNoteHeight", 272 | label: "Minimum Note height (px)", 273 | value: 10, 274 | min: 1, 275 | max: 50, 276 | step: 1, 277 | onChange: value => setSetting("minNoteHeight", value) 278 | }, 279 | { 280 | type: "slider", 281 | id: "noteEndedShrink", 282 | label: "Played Notes shrink speed", 283 | value: 1, 284 | min: 0, 285 | max: 5, 286 | step: 0.1, 287 | onChange: value => setSetting("noteEndedShrink", value) 288 | }, 289 | { 290 | type: "slider", 291 | id: "playedNoteFalloffSpeed", 292 | label: "Played Note Speed", 293 | value: 1, 294 | min: 0.1, 295 | max: 10, 296 | step: 0.1, 297 | onChange: value => setSetting("playedNoteFalloffSpeed", value) 298 | } 299 | ], 300 | Sustain: [ 301 | { 302 | type: "checkbox", 303 | id: "showSustainOnOffs", 304 | label: "Draw Sustain On/Off Events", 305 | value: false, 306 | onChange: function (ev) { 307 | setSetting("showSustainOnOffs", ev.target.checked) 308 | } 309 | }, 310 | { 311 | type: "checkbox", 312 | id: "showSustainPeriods", 313 | label: "Draw Sustain Periods", 314 | value: false, 315 | onChange: ev => setSetting("showSustainPeriods", ev.target.checked) 316 | }, 317 | { 318 | type: "checkbox", 319 | id: "showSustainedNotes", 320 | label: "Draw Sustained Notes", 321 | value: false, 322 | onChange: ev => setSetting("showSustainedNotes", ev.target.checked) 323 | }, 324 | { 325 | type: "slider", 326 | id: "sustainedNotesOpacity", 327 | label: "Sustained Notes Opacity (%)", 328 | value: 50, 329 | min: 0, 330 | max: 100, 331 | step: 1, 332 | onChange: value => setSetting("sustainedNotesOpacity", value) 333 | } 334 | ], 335 | Particles: [ 336 | { 337 | type: "checkbox", 338 | id: "showParticlesTop", 339 | label: "Enable top particles", 340 | value: true, 341 | onChange: ev => setSetting("showParticlesTop", ev.target.checked) 342 | }, 343 | { 344 | type: "checkbox", 345 | id: "showParticlesBottom", 346 | label: "Enable bottom particles ", 347 | value: true, 348 | onChange: ev => setSetting("showParticlesBottom", ev.target.checked) 349 | }, 350 | { 351 | type: "checkbox", 352 | id: "particleStroke", 353 | label: "Stroke particles", 354 | value: true, 355 | onChange: ev => setSetting("particleStroke", ev.target.checked) 356 | }, 357 | { 358 | type: "slider", 359 | id: "particleBlur", 360 | label: "Particle blur amount (px)", 361 | value: 3, 362 | min: 0, 363 | max: 10, 364 | step: 1, 365 | onChange: value => setSetting("particleBlur", value) 366 | }, 367 | { 368 | type: "slider", 369 | id: "particleAmount", 370 | label: "Particle Amount (per frame)", 371 | value: 3, 372 | min: 0, 373 | max: 15, 374 | step: 1, 375 | onChange: value => setSetting("particleAmount", value) 376 | }, 377 | { 378 | type: "slider", 379 | id: "particleSize", 380 | label: "Particle Size", 381 | value: 6, 382 | min: 0, 383 | max: 10, 384 | step: 1, 385 | onChange: value => setSetting("particleSize", value) 386 | }, 387 | { 388 | type: "slider", 389 | id: "particleLife", 390 | label: "Particle Duration", 391 | value: 20, 392 | min: 1, 393 | max: 150, 394 | step: 1, 395 | onChange: value => setSetting("particleLife", value) 396 | }, 397 | { 398 | type: "slider", 399 | id: "particleSpeed", 400 | label: "Particle Speed", 401 | value: 4, 402 | min: 1, 403 | max: 15, 404 | step: 1, 405 | onChange: value => setSetting("particleSpeed", value) 406 | } 407 | ], 408 | Background: [ 409 | { 410 | type: "color", 411 | id: "bgCol1", 412 | label: "Background fill color 1", 413 | value: "rgba(40,40,40,0.8)", 414 | onChange: value => { 415 | setSetting("bgCol1", value) 416 | } 417 | }, 418 | { 419 | type: "color", 420 | id: "bgCol2", 421 | label: "Background fill color 2", 422 | value: "rgba(25,25,25,1)", 423 | onChange: value => { 424 | setSetting("bgCol2", value) 425 | } 426 | }, 427 | { 428 | type: "color", 429 | id: "bgCol3", 430 | label: "Background stroke color", 431 | value: "rgba(10,10,10,0.5)", 432 | onChange: value => { 433 | setSetting("bgCol3", value) 434 | } 435 | } 436 | ] 437 | }, 438 | Audio: { 439 | default: [ 440 | { 441 | type: "list", 442 | id: "soundfontName", 443 | label: "Soundfont", 444 | value: "MusyngKite", 445 | list: ["MusyngKite", "FluidR3_GM", "FatBoy"], 446 | onChange: newVal => setSetting("soundfontName", newVal) 447 | }, 448 | { 449 | type: "checkbox", 450 | id: "sustainEnabled", 451 | label: "Enable Sustain", 452 | value: true, 453 | onChange: function (ev) { 454 | setSetting("sustainEnabled", ev.target.checked) 455 | }.bind(this) 456 | }, 457 | { 458 | type: "checkbox", 459 | id: "enableMetronome", 460 | label: "Enable Metronome", 461 | value: true, 462 | onChange: function (ev) { 463 | setSetting("enableMetronome", ev.target.checked) 464 | }.bind(this) 465 | }, 466 | { 467 | type: "slider", 468 | id: "metronomeVolume", 469 | label: "Metronome Volume", 470 | value: 0.5, 471 | min: 0, 472 | max: 1, 473 | step: 0.1, 474 | onChange: value => setSetting("metronomeVolume", value) 475 | } 476 | ], 477 | "ADSR Envelope": [ 478 | { 479 | type: "slider", 480 | id: "adsrAttack", 481 | label: "Attack (Seconds)", 482 | value: 0, 483 | min: 0, 484 | max: 2, 485 | step: 0.01, 486 | onChange: value => setSetting("adsrAttack", value) 487 | }, 488 | { 489 | type: "slider", 490 | id: "adsrDecay", 491 | label: "Decay (Seconds)", 492 | value: 0, 493 | min: 0, 494 | max: 0.5, 495 | step: 0.01, 496 | onChange: value => setSetting("adsrDecay", value) 497 | }, 498 | { 499 | type: "slider", 500 | id: "adsrSustain", 501 | label: "Sustain (%)", 502 | value: 100, 503 | min: 0, 504 | max: 100, 505 | step: 1, 506 | onChange: value => setSetting("adsrSustain", value) 507 | }, 508 | { 509 | type: "slider", 510 | id: "adsrReleaseKey", 511 | label: "Release - Key (Seconds)", 512 | value: 0.2, 513 | min: 0, 514 | max: 2, 515 | step: 0.01, 516 | onChange: value => setSetting("adsrReleaseKey", value) 517 | }, 518 | { 519 | type: "slider", 520 | id: "adsrReleasePedal", 521 | label: "Release - Pedal (Seconds)", 522 | value: 0.2, 523 | min: 0, 524 | max: 2, 525 | step: 0.01, 526 | onChange: value => setSetting("adsrReleasePedal", value) 527 | } 528 | ] 529 | } 530 | } 531 | -------------------------------------------------------------------------------- /js/settings/LocalStorageHandler.js: -------------------------------------------------------------------------------- 1 | import { getSettingObject } from "./Settings.js" 2 | 3 | const SAVE_PATH_ROOT = "Midiano/SavedSettings" 4 | export const getGlobalSavedSettings = () => { 5 | let obj = {} 6 | if (window.localStorage) { 7 | let storedObj = window.localStorage.getItem(SAVE_PATH_ROOT) 8 | if (storedObj) { 9 | obj = JSON.parse(storedObj) 10 | } 11 | } 12 | return obj 13 | } 14 | 15 | export const saveCurrentSettings = () => { 16 | if (window.localStorage) { 17 | let saveObj = getSettingObject() 18 | window.localStorage.setItem(SAVE_PATH_ROOT, JSON.stringify(saveObj)) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /js/settings/Settings.js: -------------------------------------------------------------------------------- 1 | import { getDefaultSettings } from "./DefaultSettings.js" 2 | import { SettingUI } from "../ui/SettingUI.js" 3 | import { 4 | getGlobalSavedSettings, 5 | saveCurrentSettings 6 | } from "./LocalStorageHandler.js" 7 | 8 | class Settings { 9 | constructor(ui) { 10 | this.settings = getDefaultSettings() 11 | let savedSettings = getGlobalSavedSettings() 12 | 13 | this.settingsById = {} 14 | Object.keys(this.settings).forEach(tabId => 15 | Object.keys(this.settings[tabId]).forEach(categoryId => 16 | this.settings[tabId][categoryId].forEach(setting => { 17 | this.settingsById[setting.id] = setting 18 | 19 | if (savedSettings.hasOwnProperty(setting.id)) { 20 | setting.value = savedSettings[setting.id] 21 | } 22 | }) 23 | ) 24 | ) 25 | this.settingsUi = new SettingUI() 26 | } 27 | setSettingValue(settingId, value) { 28 | this.settingsById[settingId].value = value 29 | } 30 | } 31 | 32 | const globalSettings = new Settings() 33 | export const getSetting = settingId => { 34 | if (globalSettings == null) { 35 | globalSettings = new Settings() 36 | } 37 | return globalSettings.settingsById[settingId] 38 | ? globalSettings.settingsById[settingId].value 39 | : null 40 | } 41 | export const setSetting = (settingId, value) => { 42 | globalSettings.settingsById[settingId].value = value 43 | if (settingCallbacks.hasOwnProperty(settingId)) { 44 | settingCallbacks[settingId].forEach(callback => callback()) 45 | } 46 | saveCurrentSettings() 47 | } 48 | export const getSettingsDiv = () => { 49 | return globalSettings.settingsUi.getSettingsDiv(globalSettings.settings) 50 | } 51 | var settingCallbacks = {} 52 | export const setSettingCallback = (settingId, callback) => { 53 | if (!settingCallbacks.hasOwnProperty(settingId)) { 54 | settingCallbacks[settingId] = [] 55 | } 56 | settingCallbacks[settingId].push(callback) 57 | } 58 | export const getSettingObject = () => { 59 | let obj = {} 60 | for (let key in globalSettings.settingsById) { 61 | obj[key] = globalSettings.settingsById[key].value 62 | } 63 | return obj 64 | } 65 | 66 | export const resetSettingsToDefault = () => { 67 | let defaultSettings = getDefaultSettings() 68 | Object.keys(defaultSettings).forEach(tabId => 69 | Object.keys(defaultSettings[tabId]).forEach(categoryId => 70 | defaultSettings[tabId][categoryId].forEach(setting => { 71 | globalSettings.settingsById[setting.id].value = setting.value 72 | }) 73 | ) 74 | ) 75 | 76 | let parent = globalSettings.settingsUi.getSettingsDiv(globalSettings.settings) 77 | .parentElement 78 | parent.removeChild( 79 | globalSettings.settingsUi.getSettingsDiv(globalSettings.settings) 80 | ) 81 | globalSettings.settingsUi.mainDiv = null 82 | parent.appendChild(getSettingsDiv()) 83 | } 84 | -------------------------------------------------------------------------------- /js/ui/DomHelper.js: -------------------------------------------------------------------------------- 1 | import { replaceAllString } from "../Util.js" 2 | 3 | export class DomHelper { 4 | static createCanvas(width, height, styles) { 5 | return DomHelper.createElement("canvas", styles, { 6 | width: width, 7 | height: height 8 | }) 9 | } 10 | static createSpinner() { 11 | return DomHelper.createDivWithIdAndClass("loadSpinner", "loader") 12 | } 13 | static setCanvasSize(cnv, width, height) { 14 | if (cnv.width != width) { 15 | cnv.width = width 16 | } 17 | if (cnv.height != height) { 18 | cnv.height = height 19 | } 20 | } 21 | static replaceGlyph(element, oldIcon, newIcon) { 22 | element.children.forEach(childNode => { 23 | if (childNode.classList.contains("glyphicon-" + oldIcon)) { 24 | childNode.className = childNode.className.replace( 25 | "glyphicon-" + oldIcon, 26 | "glyphicon-" + newIcon 27 | ) 28 | } 29 | }) 30 | } 31 | static removeClass(className, element) { 32 | if (element.classList.contains(className)) { 33 | element.classList.remove(className) 34 | } 35 | } 36 | static removeClassFromElementsSelector(selector, className) { 37 | document.querySelectorAll(selector).forEach(el => { 38 | if (el.classList.contains(className)) { 39 | el.classList.remove(className) 40 | } 41 | }) 42 | } 43 | static createSliderWithLabel(id, label, val, min, max, step, onChange) { 44 | let cont = DomHelper.createElement( 45 | "div", 46 | {}, 47 | { id: id + "container", className: "sliderContainer" } 48 | ) 49 | let labelDiv = DomHelper.createElement( 50 | "label", 51 | {}, 52 | { id: id + "label", className: "sliderLabel", innerHTML: label } 53 | ) 54 | let slider = DomHelper.createSlider(id, val, min, max, step, onChange) 55 | cont.appendChild(labelDiv) 56 | cont.appendChild(slider) 57 | return { slider: slider, container: cont } 58 | } 59 | static createSliderWithLabelAndField( 60 | id, 61 | label, 62 | val, 63 | min, 64 | max, 65 | step, 66 | onChange 67 | ) { 68 | let displayDiv = DomHelper.createElement( 69 | "div", 70 | {}, 71 | { id: id + "Field", className: "sliderVal", innerHTML: val } 72 | ) 73 | 74 | let onChangeInternal = ev => { 75 | displayDiv.innerHTML = ev.target.value 76 | onChange(ev.target.value) 77 | } 78 | 79 | let cont = DomHelper.createElement( 80 | "div", 81 | {}, 82 | { id: id + "container", className: "sliderContainer" } 83 | ) 84 | let labelDiv = DomHelper.createElement( 85 | "label", 86 | {}, 87 | { id: id + "label", className: "sliderLabel", innerHTML: label } 88 | ) 89 | let slider = DomHelper.createSlider( 90 | id, 91 | val, 92 | min, 93 | max, 94 | step, 95 | onChangeInternal 96 | ) 97 | cont.appendChild(labelDiv) 98 | cont.appendChild(displayDiv) 99 | cont.appendChild(slider) 100 | 101 | return { slider: slider, container: cont } 102 | } 103 | static createGlyphiconButton(id, glyph, onClick) { 104 | let bt = DomHelper.createButton(id, onClick) 105 | bt.appendChild(this.getGlyphicon(glyph)) 106 | return bt 107 | } 108 | static createGlyphiconTextButton(id, glyph, text, onClick) { 109 | let bt = DomHelper.createButton(id, onClick) 110 | bt.appendChild(this.getGlyphicon(glyph)) 111 | bt.innerHTML += " " + text 112 | return bt 113 | } 114 | static createDiv(styles, attributes) { 115 | return DomHelper.createElement("div", styles, attributes) 116 | } 117 | static createDivWithId(id, styles, attributes) { 118 | attributes = attributes || {} 119 | attributes.id = id 120 | return DomHelper.createElement("div", styles, attributes) 121 | } 122 | static createDivWithClass(className, styles, attributes) { 123 | attributes = attributes || {} 124 | attributes.className = className 125 | return DomHelper.createElement("div", styles, attributes) 126 | } 127 | static createDivWithIdAndClass(id, className, styles, attributes) { 128 | attributes = attributes || {} 129 | attributes.id = id 130 | attributes.className = className 131 | return DomHelper.createElement("div", styles, attributes) 132 | } 133 | static createElementWithId(id, tag, styles, attributes) { 134 | attributes = attributes || {} 135 | attributes.id = id 136 | return DomHelper.createElement(tag, styles, attributes) 137 | } 138 | static createElementWithClass(className, tag, styles, attributes) { 139 | attributes = attributes || {} 140 | attributes.className = className 141 | return DomHelper.createElement(tag, styles, attributes) 142 | } 143 | static createElementWithIdAndClass(id, className, tag, styles, attributes) { 144 | styles = styles || {} 145 | attributes = attributes || {} 146 | attributes.id = id 147 | attributes.className = className 148 | return DomHelper.createElement(tag, styles, attributes) 149 | } 150 | static getGlyphicon(name) { 151 | return DomHelper.createElement( 152 | "span", 153 | {}, 154 | { className: "glyphicon glyphicon-" + name } 155 | ) 156 | } 157 | static createSlider(id, val, min, max, step, onChange) { 158 | let el = DomHelper.createElement( 159 | "input", 160 | {}, 161 | { 162 | id: id, 163 | oninput: onChange, 164 | type: "range", 165 | value: val, 166 | min: min, 167 | max: max, 168 | step: step 169 | } 170 | ) 171 | el.value = val 172 | return el 173 | } 174 | static createTextInput(onChange, styles, attributes) { 175 | attributes = attributes || {} 176 | attributes.type = "text" 177 | attributes.onchange = onChange 178 | return DomHelper.createElement("input", styles, attributes) 179 | } 180 | static createCheckbox(text, onChange, value, isChecked) { 181 | let id = replaceAllString(text, " ", "") + "checkbox" 182 | let cont = DomHelper.createDivWithIdAndClass(id, "checkboxCont") 183 | let checkbox = DomHelper.createElementWithClass("checkboxInput", "input") 184 | checkbox.setAttribute("type", "checkbox") 185 | checkbox.checked = value 186 | checkbox.setAttribute("name", id) 187 | checkbox.onchange = onChange 188 | 189 | let label = DomHelper.createElementWithClass( 190 | "checkboxlabel", 191 | "label", 192 | {}, 193 | { innerHTML: text, for: id } 194 | ) 195 | 196 | label.setAttribute("for", id) 197 | 198 | cont.appendChild(checkbox) 199 | cont.appendChild(label) 200 | cont.addEventListener("click", ev => { 201 | if (ev.target != checkbox) { 202 | checkbox.click() 203 | if (isChecked) { 204 | checkbox.checked = isChecked() 205 | } 206 | } 207 | }) 208 | return cont 209 | } 210 | static addClassToElements(className, elements) { 211 | elements.forEach(element => DomHelper.addClassToElement(className, element)) 212 | } 213 | static addClassToElement(className, element) { 214 | if (!element.classList.contains(className)) { 215 | element.classList.add(className) 216 | } 217 | } 218 | static createFlexContainer() { 219 | return DomHelper.createElement("div", {}, { className: "flexContainer" }) 220 | } 221 | static addToFlexContainer(el) { 222 | let cont = DomHelper.createFlexContainer() 223 | cont.appendChild(el) 224 | return cont 225 | } 226 | static appendChildren(parent, children) { 227 | children.forEach(child => parent.appendChild(child)) 228 | } 229 | static createButtonGroup(vertical) { 230 | return vertical 231 | ? DomHelper.createElement( 232 | "div", 233 | { justifyContent: "space-around" }, 234 | { className: "btn-group btn-group-vertical", role: "group" } 235 | ) 236 | : DomHelper.createElement( 237 | "div", 238 | { justifyContent: "space-around" }, 239 | { className: "btn-group", role: "group" } 240 | ) 241 | } 242 | static createFileInput(text, callback) { 243 | let customFile = DomHelper.createElement( 244 | "label", 245 | {}, 246 | { className: "btn btn-default btn-file" } 247 | ) 248 | customFile.appendChild(DomHelper.getGlyphicon("folder-open")) 249 | customFile.innerHTML += " " + text 250 | let inp = DomHelper.createElement( 251 | "input", 252 | { display: "none" }, 253 | { type: "file" } 254 | ) 255 | 256 | customFile.appendChild(inp) 257 | inp.onchange = callback 258 | 259 | return customFile 260 | } 261 | static getDivider() { 262 | return DomHelper.createElement("div", {}, { className: "divider" }) 263 | } 264 | static createButton(id, onClick) { 265 | let bt = DomHelper.createElement( 266 | "button", 267 | {}, 268 | { 269 | id: id, 270 | type: "button", 271 | className: "btn btn-default", 272 | onclick: onClick 273 | } 274 | ) 275 | bt.appendChild(DomHelper.getButtonSelectLine()) 276 | return bt 277 | } 278 | static createTextButton(id, text, onClick) { 279 | let bt = DomHelper.createElement( 280 | "button", 281 | {}, 282 | { 283 | id: id, 284 | type: "button", 285 | className: "btn btn-default", 286 | onclick: onClick, 287 | innerHTML: text 288 | } 289 | ) 290 | bt.appendChild(DomHelper.getButtonSelectLine()) 291 | return bt 292 | } 293 | static getButtonSelectLine() { 294 | return DomHelper.createDivWithClass("btn-select-line") 295 | } 296 | static createElement(tag, styles, attributes) { 297 | tag = tag || "div" 298 | attributes = attributes || {} 299 | styles = styles || {} 300 | let el = document.createElement(tag) 301 | Object.keys(attributes).forEach(attr => { 302 | el[attr] = attributes[attr] 303 | }) 304 | Object.keys(styles).forEach(style => { 305 | el.style[style] = styles[style] 306 | }) 307 | return el 308 | } 309 | 310 | static createInputSelect(title, items, callback) { 311 | let selectBox = DomHelper.createDivWithId(title) 312 | let label = DomHelper.createElementWithClass( 313 | "inputSelectLabel", 314 | "label", 315 | {}, 316 | { innerHTML: title } 317 | ) 318 | selectBox.appendChild(label) 319 | let selectTag = DomHelper.createElementWithIdAndClass( 320 | title, 321 | "inputSelect", 322 | "select" 323 | ) 324 | selectBox.appendChild(selectTag) 325 | items.forEach((item, index) => { 326 | let option = DomHelper.createElement( 327 | "option", 328 | {}, 329 | { 330 | value: item, 331 | innerHTML: item 332 | } 333 | ) 334 | selectTag.appendChild(option) 335 | }) 336 | selectBox.addEventListener("change", ev => { 337 | callback(selectTag.value) 338 | }) 339 | return selectBox 340 | } 341 | 342 | static createColorPickerGlyphiconText(glyph, text, startColor, onChange) { 343 | let pickrEl = null 344 | let pickrElCont = DomHelper.createDiv() 345 | let glyphBut = DomHelper.createGlyphiconTextButton( 346 | "colorPickerGlyph" + glyph + replaceAllString(text, " ", "_"), 347 | glyph, 348 | text, 349 | () => { 350 | pickrEl.show() 351 | } 352 | ) 353 | 354 | glyphBut.appendChild(pickrElCont) 355 | 356 | pickrEl = Pickr.create({ 357 | el: pickrElCont, 358 | theme: "nano", 359 | useAsButton: true, 360 | components: { 361 | hue: true, 362 | preview: true, 363 | opacity: true, 364 | interaction: { 365 | input: true 366 | } 367 | } 368 | }) 369 | 370 | let getGlyphEl = () => 371 | glyphBut.querySelector( 372 | "#colorPickerGlyph" + 373 | glyph + 374 | replaceAllString(text, " ", "_") + 375 | " .glyphicon" 376 | ) 377 | 378 | pickrEl.on("init", () => { 379 | pickrEl.setColor(startColor) 380 | getGlyphEl().style.color = startColor 381 | }) 382 | pickrEl.on("change", color => { 383 | let colorString = color.toRGBA().toString() 384 | getGlyphEl().style.color = colorString 385 | onChange(colorString) 386 | }) 387 | return glyphBut 388 | } 389 | /** 390 | * 391 | * @param {String} text 392 | * @param {String} startColor 393 | * @param {Function} onChange A color string of the newly selected color will be passed as argument 394 | */ 395 | static createColorPickerText(text, startColor, onChange) { 396 | let cont = DomHelper.createDivWithClass("settingContainer") 397 | 398 | let label = DomHelper.createDivWithClass( 399 | "colorLabel settingLabel", 400 | {}, 401 | { innerHTML: text } 402 | ) 403 | 404 | let colorButtonContainer = DomHelper.createDivWithClass( 405 | "colorPickerButtonContainer" 406 | ) 407 | let colorButton = DomHelper.createDivWithClass("colorPickerButton") 408 | colorButtonContainer.appendChild(colorButton) 409 | 410 | cont.appendChild(label) 411 | cont.appendChild(colorButtonContainer) 412 | 413 | let colorPicker = Pickr.create({ 414 | el: colorButton, 415 | theme: "nano", 416 | defaultRepresentation: "RGBA", 417 | components: { 418 | hue: true, 419 | preview: true, 420 | opacity: true, 421 | interaction: { 422 | input: true 423 | } 424 | } 425 | }) 426 | colorButtonContainer.style.backgroundColor = startColor 427 | cont.onclick = () => colorPicker.show() 428 | colorPicker.on("init", () => { 429 | colorPicker.show() 430 | colorPicker.setColor(startColor) 431 | colorPicker.hide() 432 | }) 433 | colorPicker.on("change", color => { 434 | colorButtonContainer.style.backgroundColor = colorPicker 435 | .getColor() 436 | .toRGBA() 437 | .toString() 438 | onChange(color.toRGBA().toString()) 439 | }) 440 | 441 | return cont 442 | } 443 | } 444 | -------------------------------------------------------------------------------- /js/ui/ElementHighlight.js: -------------------------------------------------------------------------------- 1 | export class ElementHighlight { 2 | constructor(element, time) { 3 | time = time || 1500 4 | 5 | element.classList.add("highlighted") 6 | window.setTimeout(() => { 7 | element.classList.remove("highlighted") 8 | }, time) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /js/ui/Loader.js: -------------------------------------------------------------------------------- 1 | import { DomHelper } from "./DomHelper.js" 2 | class Loader { 3 | startLoad() { 4 | this.getLoadingDiv().style.display = "block" 5 | this.getLoadingText().innerHTML = "Loading" 6 | this.loading = true 7 | this.loadAnimation() 8 | } 9 | stopLoad() { 10 | this.getLoadingDiv().style.display = "none" 11 | this.loading = false 12 | } 13 | loadAnimation() { 14 | let currentText = this.getLoadingText().innerHTML 15 | currentText = currentText.replace("...", " ..") 16 | currentText = currentText.replace(" ..", ". .") 17 | currentText = currentText.replace(". .", ".. ") 18 | currentText = currentText.replace(".. ", "...") 19 | this.getLoadingText().innerHTML = currentText 20 | if (this.loading) { 21 | window.requestAnimationFrame(this.loadAnimation.bind(this)) 22 | } 23 | } 24 | setLoadMessage(msg) { 25 | this.getLoadingText().innerHTML = msg + "..." 26 | } 27 | getLoadingText() { 28 | if (!this.loadingText) { 29 | this.loadingText = DomHelper.createElement("p") 30 | this.getLoadingDiv().appendChild(this.loadingText) 31 | } 32 | return this.loadingText 33 | } 34 | getLoadingDiv() { 35 | if (!this.loadingDiv) { 36 | this.loadingDiv = DomHelper.createDivWithIdAndClass( 37 | "loadingDiv", 38 | "fullscreen" 39 | ) 40 | 41 | let spinner = DomHelper.createSpinner() 42 | this.loadingDiv.appendChild(spinner) 43 | document.body.appendChild(this.loadingDiv) 44 | } 45 | return this.loadingDiv 46 | } 47 | } 48 | 49 | export const getLoader = () => loaderSingleton 50 | const loaderSingleton = new Loader() 51 | -------------------------------------------------------------------------------- /js/ui/Notification.js: -------------------------------------------------------------------------------- 1 | import { DomHelper } from "./DomHelper.js" 2 | 3 | export class Notification { 4 | static create(message, time) { 5 | time = time || 1500 6 | let notifEl = DomHelper.createDivWithClass("notification") 7 | notifEl.innerHTML = message 8 | document.body.appendChild(notifEl) 9 | window.setTimeout(() => document.body.removeChild(notifEl), time) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /js/ui/SettingUI.js: -------------------------------------------------------------------------------- 1 | import { resetSettingsToDefault } from "../settings/Settings.js" 2 | import { DomHelper } from "../ui/DomHelper.js" 3 | import { groupArrayBy } from "../Util.js" 4 | /** 5 | * Class to create the DOM Elements used to manipulate the settings. 6 | */ 7 | export class SettingUI { 8 | constructor() { 9 | this.tabs = {} 10 | this.activeTab = "General" 11 | this.mainDiv = null 12 | } 13 | /** 14 | * returns a div with the following structure: 15 | * .settingsContainer { 16 | * .settingsTabButtonContainer { 17 | * .settingsTabButton ... 18 | * } 19 | * .settingsContentContainer { 20 | * .settingContainer ... 21 | * } 22 | * } 23 | * 24 | * @param {Object} settings as defined in DefaultSettings.js 25 | */ 26 | getSettingsDiv(settings) { 27 | if (this.mainDiv == null) { 28 | this.mainDiv = DomHelper.createDivWithClass("settingsContainer") 29 | this.mainDiv.appendChild(this.getTabDiv(Object.keys(settings))) 30 | this.mainDiv.appendChild(this.getContentDiv(settings)) 31 | 32 | this.mainDiv 33 | .querySelectorAll(".settingsTabContent" + this.activeTab) 34 | .forEach(el => (el.style.display = "block")) 35 | this.mainDiv 36 | .querySelector("#" + this.activeTab + "Tab") 37 | .classList.add("selected") 38 | } 39 | return this.mainDiv 40 | } 41 | getTabDiv(tabIds) { 42 | let cont = DomHelper.createDivWithClass("settingsTabButtonContainer") 43 | tabIds.forEach(tabId => { 44 | let tabButton = this.createTabButton(tabId) 45 | tabButton.classList.add("settingsTabButton") 46 | cont.appendChild(tabButton) 47 | }) 48 | return cont 49 | } 50 | createTabButton(tabName) { 51 | let butEl = DomHelper.createTextButton(tabName + "Tab", tabName, ev => { 52 | document 53 | .querySelectorAll(".settingsTabButton") 54 | .forEach(el => el.classList.remove("selected")) 55 | 56 | butEl.classList.add("selected") 57 | 58 | document 59 | .querySelectorAll(".settingsTabContentContainer") 60 | .forEach(settingEl => (settingEl.style.display = "none")) 61 | document 62 | .querySelectorAll(".settingsTabContent" + tabName) 63 | .forEach(settingEl => (settingEl.style.display = "block")) 64 | }) 65 | return butEl 66 | } 67 | getContentDiv(settings) { 68 | let cont = DomHelper.createDivWithClass("settingsContentContainer") 69 | Object.keys(settings).forEach(tabId => { 70 | cont.appendChild(this.createSettingTabContentDiv(tabId, settings[tabId])) 71 | }) 72 | 73 | return cont 74 | } 75 | createSettingTabContentDiv(tabName, settingGroups) { 76 | let cont = DomHelper.createDivWithClass( 77 | "settingsTabContentContainer settingsTabContent" + tabName 78 | ) 79 | Object.keys(settingGroups).forEach(groupId => { 80 | cont.appendChild( 81 | this.createSettingGroupDiv(groupId, settingGroups[groupId]) 82 | ) 83 | }) 84 | if (tabName == "General") { 85 | cont.appendChild(this.getResetButton()) 86 | } 87 | return cont 88 | } 89 | getResetButton() { 90 | let but = DomHelper.createTextButton( 91 | "settingsResetButton", 92 | "Reset to defaults", 93 | () => { 94 | resetSettingsToDefault() 95 | } 96 | ) 97 | return but 98 | } 99 | createSettingGroupDiv(categoryName, settingsList) { 100 | let cont = DomHelper.createDivWithClass( 101 | "settingsGroupContainer innerMenuContDiv" 102 | ) 103 | if (categoryName != "default") { 104 | cont.classList.add("collapsed") 105 | let label = DomHelper.createElementWithClass( 106 | "settingsGroupLabel clickableTitle", 107 | "div", 108 | {}, 109 | { innerHTML: categoryName + ": " } 110 | ) 111 | cont.appendChild(label) 112 | 113 | let collapsed = true 114 | let glyph = DomHelper.getGlyphicon("plus") 115 | glyph.classList.add("collapserGlyphSpan") 116 | label.appendChild(glyph) 117 | 118 | label.onclick = () => { 119 | if (collapsed == true) { 120 | collapsed = false 121 | cont.classList.remove("collapsed") 122 | DomHelper.replaceGlyph(label, "plus", "minus") 123 | } else { 124 | collapsed = true 125 | cont.classList.add("collapsed") 126 | DomHelper.replaceGlyph(label, "minus", "plus") 127 | } 128 | } 129 | } 130 | 131 | settingsList.forEach(setting => 132 | cont.appendChild(SettingUI.createSettingDiv(setting)) 133 | ) 134 | return cont 135 | } 136 | static createSettingDiv(setting) { 137 | switch (setting.type) { 138 | case "list": 139 | return SettingUI.createListSettingDiv(setting) 140 | case "checkbox": 141 | return SettingUI.createCheckboxSettingDiv(setting) 142 | case "slider": 143 | return SettingUI.createSliderSettingDiv(setting) 144 | case "color": 145 | return SettingUI.createColorSettingDiv(setting) 146 | } 147 | } 148 | static createListSettingDiv(setting) { 149 | let el = DomHelper.createInputSelect( 150 | setting.label, 151 | setting.list, 152 | setting.onChange 153 | ) 154 | el.classList.add("settingContainer") 155 | return el 156 | } 157 | static createCheckboxSettingDiv(setting) { 158 | let el = DomHelper.createCheckbox( 159 | setting.label, 160 | setting.onChange, 161 | setting.value, 162 | setting.isChecked 163 | ) 164 | el.classList.add("settingContainer") 165 | return el 166 | } 167 | static createSliderSettingDiv(setting) { 168 | let el = DomHelper.createSliderWithLabelAndField( 169 | setting.id + "Slider", 170 | setting.label, 171 | parseFloat(setting.value), 172 | setting.min, 173 | setting.max, 174 | setting.step, 175 | setting.onChange 176 | ).container 177 | el.classList.add("settingContainer") 178 | return el 179 | } 180 | static createColorSettingDiv(setting) { 181 | return DomHelper.createColorPickerText( 182 | setting.label, 183 | setting.value, 184 | setting.onChange 185 | ) 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /js/ui/SongUI.js: -------------------------------------------------------------------------------- 1 | import { FileLoader } from "../player/FileLoader.js" 2 | import { getCurrentSong, getPlayer } from "../player/Player.js" 3 | import { replaceAllString } from "../Util.js" 4 | import { DomHelper } from "./DomHelper.js" 5 | import { getLoader } from "./Loader.js" 6 | 7 | export class SongUI { 8 | constructor() { 9 | this.songDivs = {} 10 | this.wrapper = DomHelper.createDiv() 11 | } 12 | getDivContent() { 13 | return this.wrapper 14 | } 15 | setExampleSongs(jsonSongs) { 16 | jsonSongs.forEach(exampleSongJson => { 17 | let songDivObj = createUnloadedSongDiv(exampleSongJson) 18 | this.songDivs[exampleSongJson.fileName] = songDivObj 19 | this.wrapper.appendChild(songDivObj.wrapper) 20 | }) 21 | } 22 | newSongCallback(song) { 23 | if (!this.songDivs.hasOwnProperty(song.fileName)) { 24 | let songDivObj = createLoadedSongDiv(song) 25 | this.songDivs[song.fileName] = songDivObj 26 | this.wrapper.appendChild(songDivObj.wrapper) 27 | } else { 28 | this.replaceNowLoadedSongDiv(song) 29 | } 30 | DomHelper.removeClassFromElementsSelector(".songButton", "selected") 31 | DomHelper.addClassToElement("selected", song.div) 32 | } 33 | replaceNowLoadedSongDiv(song) { 34 | song.div = this.songDivs[song.fileName].button 35 | song.div.onclick = () => loadedButtonClickCallback(song) 36 | } 37 | } 38 | function createUnloadedSongDiv(songJson) { 39 | let wrapper = DomHelper.createDivWithIdAndClass( 40 | "songWrap" + replaceAllString(songJson.fileName, " ", "_"), 41 | "innerMenuContDiv" 42 | ) 43 | let button = createUnloadedSongButton(songJson) 44 | 45 | wrapper.appendChild(button) 46 | 47 | return { 48 | wrapper: wrapper, 49 | name: songJson.name, 50 | button: button 51 | } 52 | } 53 | 54 | function createLoadedSongDiv(song) { 55 | let wrapper = DomHelper.createDivWithIdAndClass( 56 | "songWrap" + replaceAllString(song.fileName, " ", "_"), 57 | "innerMenuContDiv" 58 | ) 59 | let button = createLoadedSongButton(song) 60 | song.div = button 61 | wrapper.appendChild(song.div) 62 | 63 | return { 64 | wrapper: wrapper, 65 | name: song.name, 66 | button: button 67 | } 68 | } 69 | function createUnloadedSongButton(songJson) { 70 | let but = DomHelper.createTextButton( 71 | "song" + replaceAllString(songJson.fileName, " ", "_"), 72 | songJson.name, 73 | () => { 74 | getLoader().setLoadMessage("Downloading Song") 75 | FileLoader.loadSongFromURL(songJson.url, respone => { 76 | getPlayer().loadSong(respone, songJson.fileName, songJson.name) 77 | }) 78 | } 79 | ) 80 | but.classList.add("songButton") 81 | return but 82 | } 83 | function createLoadedSongButton(song) { 84 | let but = DomHelper.createTextButton( 85 | "song" + replaceAllString(song.fileName, " ", "_"), 86 | song.name, 87 | () => loadedButtonClickCallback(song) 88 | ) 89 | but.classList.add("songButton") 90 | return but 91 | } 92 | 93 | function loadedButtonClickCallback(song) { 94 | let currentSong = getCurrentSong() 95 | if (currentSong != song) { 96 | DomHelper.removeClassFromElementsSelector(".songButton", "selected") 97 | DomHelper.addClassToElement("selected", song.div) 98 | getPlayer().setSong(song) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /js/ui/TrackUI.js: -------------------------------------------------------------------------------- 1 | import { DomHelper } from "./DomHelper.js" 2 | import { 3 | getTrack, 4 | getTrackColor, 5 | getTracks, 6 | setTrackColor 7 | } from "../player/Tracks.js" 8 | import { getPlayer } from "../player/Player.js" 9 | import { SettingUI } from "./SettingUI.js" 10 | import { ElementHighlight } from "./ElementHighlight.js" 11 | import { Notification } from "./Notification.js" 12 | import { getMidiHandler } from "../MidiInputHandler.js" 13 | 14 | /** 15 | * Handles creation of the Track-Divs that give control over volume, diplay, color... 16 | * 17 | * Directly changes values in the track objects 18 | */ 19 | 20 | export const createTrackDivs = () => { 21 | return Object.keys(getTracks()).map(trackId => createTrackDiv(trackId)) 22 | } 23 | 24 | export const createTrackDiv = trackId => { 25 | const trackObj = getTrack(trackId) 26 | let volumeSlider, 27 | muteButton, 28 | hideButton, 29 | trackName, 30 | instrumentName, 31 | requireToPlayAlongButton, 32 | clickableTitleDiv 33 | 34 | let trackDiv = DomHelper.createDivWithIdAndClass( 35 | "trackDiv" + trackId, 36 | "innerMenuContDiv settingGroupContainer", 37 | { 38 | borderLeft: "5px solid " + getTrackColor(trackId).white 39 | } 40 | ) 41 | 42 | clickableTitleDiv = DomHelper.createDivWithClass("clickableTitle") 43 | let collapsed = instrumentName == "percussion" ? true : false 44 | 45 | let glyph = DomHelper.getGlyphicon(collapsed ? "plus" : "minus") 46 | glyph.classList.add("collapserGlyphSpan") 47 | clickableTitleDiv.appendChild(glyph) 48 | 49 | if (collapsed) { 50 | trackDiv.classList.add("collapsed") 51 | } 52 | clickableTitleDiv.onclick = () => { 53 | if (collapsed) { 54 | collapsed = false 55 | trackDiv.classList.remove("collapsed") 56 | DomHelper.replaceGlyph(clickableTitleDiv, "plus", "minus") 57 | } else { 58 | collapsed = true 59 | trackDiv.classList.add("collapsed") 60 | DomHelper.replaceGlyph(clickableTitleDiv, "minus", "plus") 61 | } 62 | } 63 | 64 | //Name 65 | trackName = DomHelper.createDivWithIdAndClass( 66 | "trackName" + trackId, 67 | "trackName" 68 | ) 69 | trackName.innerHTML = trackObj.name || "Track " + trackId 70 | 71 | //Instrument 72 | let currentInstrument = getPlayer().getCurrentTrackInstrument(trackObj.index) 73 | instrumentName = DomHelper.createDivWithIdAndClass( 74 | "instrumentName" + trackObj.index, 75 | "instrumentName" 76 | ) 77 | instrumentName.innerHTML = currentInstrument 78 | 79 | window.setInterval( 80 | () => 81 | (instrumentName.innerHTML = getPlayer().getCurrentTrackInstrument( 82 | trackObj.index 83 | )), 84 | 2000 85 | ) 86 | 87 | let btnGrp = DomHelper.createButtonGroup(false) 88 | 89 | //Track Volume 90 | volumeSlider = SettingUI.createSettingDiv({ 91 | type: "slider", 92 | label: "Volume ", 93 | value: trackObj.volume, 94 | min: 0, 95 | max: 200, 96 | step: 1, 97 | onChange: value => { 98 | if (trackObj.volume == 0 && value != 0) { 99 | muteButton.querySelector("input").checked = false 100 | } else if (trackObj.volume != 0 && value == 0) { 101 | muteButton.querySelector("input").checked = true 102 | } 103 | trackObj.volume = parseInt(value) 104 | } 105 | }) 106 | // DomHelper.createSliderWithLabel( 107 | // "volume" + trackId, 108 | // "Volume", 109 | // trackObj.volume, 110 | // 0, 111 | // 200, 112 | // 1, 113 | // ev => { 114 | // trackObj.volume = parseInt(ev.target.value) 115 | // } 116 | // ) 117 | 118 | //Hide Track 119 | hideButton = SettingUI.createSettingDiv({ 120 | type: "checkbox", 121 | label: "Show track", 122 | value: trackObj.draw, 123 | onChange: () => { 124 | if (trackObj.draw) { 125 | trackObj.draw = false 126 | } else { 127 | trackObj.draw = true 128 | } 129 | } 130 | }) 131 | 132 | //Mute Track 133 | muteButton = SettingUI.createSettingDiv({ 134 | type: "checkbox", 135 | label: "Mute track", 136 | value: trackObj.volume == 0, 137 | onChange: () => { 138 | let volumeSliderInput = volumeSlider.querySelector("input") 139 | let volumeSliderLabel = volumeSlider.querySelector(".sliderVal") 140 | if (trackObj.volume == 0) { 141 | let volume = trackObj.volumeAtMute || 100 142 | trackObj.volume = volume 143 | volumeSliderInput.value = volume 144 | trackObj.volumeAtMute = 0 145 | volumeSliderLabel.innerHTML = volume 146 | } else { 147 | trackObj.volumeAtMute = trackObj.volume 148 | trackObj.volume = 0 149 | volumeSliderInput.value = 0 150 | volumeSliderLabel.innerHTML = 0 151 | } 152 | } 153 | }) 154 | 155 | //Require Track to play along 156 | requireToPlayAlongButton = SettingUI.createSettingDiv({ 157 | type: "checkbox", 158 | label: "Require playalong", 159 | value: trackObj.requiredToPlay, 160 | isChecked: () => trackObj.requiredToPlay, 161 | onChange: () => { 162 | console.log(trackObj.requiredToPlay) 163 | if (!trackObj.requiredToPlay) { 164 | if (!getMidiHandler().isInputActive()) { 165 | Notification.create( 166 | "You have to choose a Midi Input Device to play along.", 167 | 5000 168 | ) 169 | new ElementHighlight(document.querySelector("#midiInput")) 170 | 171 | return 172 | } 173 | trackObj.requiredToPlay = true 174 | } else { 175 | trackObj.requiredToPlay = false 176 | } 177 | } 178 | }) 179 | 180 | let colorPickerWhite = SettingUI.createColorSettingDiv({ 181 | type: "color", 182 | label: "White note color", 183 | value: getTrackColor(trackId).white, 184 | onChange: colorString => { 185 | trackDiv.style.borderLeft = "5px solid " + colorString 186 | setTrackColor(trackId, "white", colorString) 187 | } 188 | }) 189 | let colorPickerBlack = SettingUI.createColorSettingDiv({ 190 | type: "color", 191 | label: "Black note color", 192 | value: getTrackColor(trackId).black, 193 | onChange: colorString => setTrackColor(trackId, "black", colorString) 194 | }) 195 | 196 | DomHelper.appendChildren(btnGrp, [ 197 | hideButton, 198 | muteButton, 199 | DomHelper.getDivider(), 200 | requireToPlayAlongButton, 201 | DomHelper.getDivider(), 202 | colorPickerWhite, 203 | colorPickerBlack 204 | ]) 205 | 206 | DomHelper.appendChildren(clickableTitleDiv, [trackName, instrumentName]) 207 | DomHelper.appendChildren(trackDiv, [ 208 | clickableTitleDiv, 209 | DomHelper.getDivider(), 210 | volumeSlider, 211 | btnGrp 212 | ]) 213 | 214 | return trackDiv 215 | } 216 | -------------------------------------------------------------------------------- /js/ui/ZoomUI.js: -------------------------------------------------------------------------------- 1 | import { getPlayer } from "../player/Player.js" 2 | import { DomHelper } from "./DomHelper.js" 3 | 4 | export class ZoomUI { 5 | constructor() {} 6 | getContentDiv(render) { 7 | let cont = DomHelper.createDivWithClass("zoomGroup btn-group") 8 | //zoomIn 9 | cont.appendChild( 10 | DomHelper.createGlyphiconButton("zoomInButton", "zoom-in", () => 11 | render.renderDimensions.zoomIn() 12 | ) 13 | ) 14 | 15 | //zoomOut 16 | cont.appendChild( 17 | DomHelper.createGlyphiconButton("zoomOutButton", "zoom-out", () => 18 | render.renderDimensions.zoomOut() 19 | ) 20 | ) 21 | //moveLeft 22 | cont.appendChild( 23 | DomHelper.createGlyphiconButton("moveViewLeftButton", "arrow-left", () => 24 | render.renderDimensions.moveViewLeft() 25 | ) 26 | ) 27 | 28 | //moveRight 29 | cont.appendChild( 30 | DomHelper.createGlyphiconButton("moveViewLeftButton", "arrow-right", () => 31 | render.renderDimensions.moveViewRight() 32 | ) 33 | ) 34 | const fitSongButton = DomHelper.createTextButton( 35 | "fitSongButton", 36 | "Fit Song", 37 | () => render.renderDimensions.fitSong(getPlayer().song.getNoteRange()) 38 | ) 39 | fitSongButton.style.float = "none" 40 | //FitSong 41 | cont.appendChild(fitSongButton) 42 | //ShowAll 43 | cont.appendChild( 44 | DomHelper.createTextButton("showAllButton", "Show All", () => 45 | render.renderDimensions.showAll() 46 | ) 47 | ) 48 | return cont 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/Base64.js: -------------------------------------------------------------------------------- 1 | //https://github.com/davidchambers/Base64.js 2 | 3 | ;(function () { 4 | var object = typeof exports != 'undefined' ? exports : this; // #8: web workers 5 | var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; 6 | 7 | function InvalidCharacterError(message) { 8 | this.message = message; 9 | } 10 | InvalidCharacterError.prototype = new Error; 11 | InvalidCharacterError.prototype.name = 'InvalidCharacterError'; 12 | 13 | // encoder 14 | // [https://gist.github.com/999166] by [https://github.com/nignag] 15 | object.btoa || ( 16 | object.btoa = function (input) { 17 | for ( 18 | // initialize result and counter 19 | var block, charCode, idx = 0, map = chars, output = ''; 20 | // if the next input index does not exist: 21 | // change the mapping table to "=" 22 | // check if d has no fractional digits 23 | input.charAt(idx | 0) || (map = '=', idx % 1); 24 | // "8 - idx % 1 * 8" generates the sequence 2, 4, 6, 8 25 | output += map.charAt(63 & block >> 8 - idx % 1 * 8) 26 | ) { 27 | charCode = input.charCodeAt(idx += 3/4); 28 | if (charCode > 0xFF) { 29 | throw new InvalidCharacterError("'btoa' failed: The string to be encoded contains characters outside of the Latin1 range."); 30 | } 31 | block = block << 8 | charCode; 32 | } 33 | return output; 34 | }); 35 | 36 | // decoder 37 | // [https://gist.github.com/1020396] by [https://github.com/atk] 38 | object.atob || ( 39 | object.atob = function (input) { 40 | input = input.replace(/=+$/, '') 41 | if (input.length % 4 == 1) { 42 | throw new InvalidCharacterError("'atob' failed: The string to be decoded is not correctly encoded."); 43 | } 44 | for ( 45 | // initialize result and counters 46 | var bc = 0, bs, buffer, idx = 0, output = ''; 47 | // get next character 48 | buffer = input.charAt(idx++); 49 | // character found in table? initialize bit storage and add its ascii value; 50 | ~buffer && (bs = bc % 4 ? bs * 64 + buffer : buffer, 51 | // and if not first of each 4 characters, 52 | // convert the first 8 bits to one ascii character 53 | bc++ % 4) ? output += String.fromCharCode(255 & bs >> (-2 * bc & 6)) : 0 54 | ) { 55 | // try to find character in table (0-63, not found => -1) 56 | buffer = chars.indexOf(buffer); 57 | } 58 | return output; 59 | }); 60 | 61 | }()); -------------------------------------------------------------------------------- /lib/Base64binary.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license ------------------------------------------------------------------- 3 | * module: Base64Binary 4 | * src: http://blog.danguer.com/2011/10/24/base64-binary-decoding-in-javascript/ 5 | * license: Simplified BSD License 6 | * ------------------------------------------------------------------- 7 | * Copyright 2011, Daniel Guerrero. All rights reserved. 8 | * 9 | * Redistribution and use in source and binary forms, with or without 10 | * modification, are permitted provided that the following conditions are met: 11 | * - Redistributions of source code must retain the above copyright 12 | * notice, this list of conditions and the following disclaimer. 13 | * - Redistributions in binary form must reproduce the above copyright 14 | * notice, this list of conditions and the following disclaimer in the 15 | * documentation and/or other materials provided with the distribution. 16 | * 17 | * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | * DISCLAIMED. IN NO EVENT SHALL DANIEL GUERRERO BE LIABLE FOR ANY 21 | * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 22 | * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 24 | * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | */ 28 | 29 | var Base64Binary = { 30 | _keyStr : "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=", 31 | 32 | /* will return a Uint8Array type */ 33 | decodeArrayBuffer: function(input) { 34 | var bytes = Math.ceil( (3*input.length) / 4.0); 35 | var ab = new ArrayBuffer(bytes); 36 | this.decode(input, ab); 37 | 38 | return ab; 39 | }, 40 | 41 | decode: function(input, arrayBuffer) { 42 | //get last chars to see if are valid 43 | var lkey1 = this._keyStr.indexOf(input.charAt(input.length-1)); 44 | var lkey2 = this._keyStr.indexOf(input.charAt(input.length-1)); 45 | 46 | var bytes = Math.ceil( (3*input.length) / 4.0); 47 | if (lkey1 == 64) bytes--; //padding chars, so skip 48 | if (lkey2 == 64) bytes--; //padding chars, so skip 49 | 50 | var uarray; 51 | var chr1, chr2, chr3; 52 | var enc1, enc2, enc3, enc4; 53 | var i = 0; 54 | var j = 0; 55 | 56 | if (arrayBuffer) 57 | uarray = new Uint8Array(arrayBuffer); 58 | else 59 | uarray = new Uint8Array(bytes); 60 | 61 | input = input.replace(/[^A-Za-z0-9\+\/\=]/g, ""); 62 | 63 | for (i=0; i> 4); 71 | chr2 = ((enc2 & 15) << 4) | (enc3 >> 2); 72 | chr3 = ((enc3 & 3) << 6) | enc4; 73 | 74 | uarray[i] = chr1; 75 | if (enc3 != 64) uarray[i+1] = chr2; 76 | if (enc4 != 64) uarray[i+2] = chr3; 77 | } 78 | 79 | return uarray; 80 | } 81 | }; -------------------------------------------------------------------------------- /lib/JASMID LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010, Matt Westcott & Ben Firshman 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | * The names of its contributors may not be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 19 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 22 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /lib/Pickr/nano.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bewelge/MIDIano/c86cc1c9c39041a873aa8023589971f90cdc2ae1/lib/Pickr/nano.css -------------------------------------------------------------------------------- /metronome/1.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bewelge/MIDIano/c86cc1c9c39041a873aa8023589971f90cdc2ae1/metronome/1.wav -------------------------------------------------------------------------------- /metronome/2.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bewelge/MIDIano/c86cc1c9c39041a873aa8023589971f90cdc2ae1/metronome/2.wav -------------------------------------------------------------------------------- /mz_331_3.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bewelge/MIDIano/c86cc1c9c39041a873aa8023589971f90cdc2ae1/mz_331_3.mid -------------------------------------------------------------------------------- /screenShotNew.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bewelge/MIDIano/c86cc1c9c39041a873aa8023589971f90cdc2ae1/screenShotNew.png --------------------------------------------------------------------------------