├── .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 | 
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 |
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
--------------------------------------------------------------------------------