├── .editorconfig
├── .gitignore
├── .prettierrc
├── README.md
├── dev
├── docs
│ └── stack.md
└── test
│ └── helpers.spec.js
├── guide.htm
├── index.htm
├── src
├── assets
│ ├── favicon
│ │ ├── android-chrome-192x192.png
│ │ ├── android-chrome-512x512.png
│ │ ├── apple-touch-icon.png
│ │ ├── browserconfig.xml
│ │ ├── favicon-16x16.png
│ │ ├── favicon-32x32.png
│ │ ├── favicon.ico
│ │ ├── manifest.json
│ │ ├── mstile-150x150.png
│ │ └── safari-pinned-tab.svg
│ └── img
│ │ ├── guide
│ │ ├── cps.png
│ │ ├── dark-mode.png
│ │ ├── enumerate-chord.png
│ │ ├── equal-temperament.png
│ │ ├── general-settings.png
│ │ ├── harmonic-series.png
│ │ ├── isomorphic-settings.png
│ │ ├── maxmsp-coll.png
│ │ ├── menu-export.png
│ │ ├── menu-modify.png
│ │ ├── menu-new.png
│ │ ├── midi-io-settings.png
│ │ ├── preset-scales.png
│ │ ├── qwerty-disabled.png
│ │ ├── qwerty-enabled.png
│ │ ├── random-variance.png
│ │ ├── rank-2-temperament.png
│ │ ├── reaper-named-notes.png
│ │ ├── scale-entry.png
│ │ ├── stretch-compress.png
│ │ ├── subharmonic-series.png
│ │ ├── subset.png
│ │ ├── synth-settings.png
│ │ ├── tempo-sync-beating.png
│ │ └── virtual-keyboard.png
│ │ └── scale-workshop-og-image.png
├── css
│ ├── style-dark.css
│ └── style.css
├── js
│ ├── constants.js
│ ├── events.js
│ ├── exporters.js
│ ├── generators.js
│ ├── graphics.js
│ ├── helpers.js
│ ├── keymap.js
│ ├── midi
│ │ ├── commands.js
│ │ ├── constants.js
│ │ ├── math.js
│ │ ├── midi.js
│ │ └── ui.js
│ ├── modifiers.js
│ ├── scaleworkshop.js
│ ├── state
│ │ ├── actions-dom.js
│ │ ├── actions.js
│ │ ├── on-ready.js
│ │ ├── reactions-dom.js
│ │ ├── reactions.js
│ │ └── state.js
│ ├── synth.js
│ ├── synth
│ │ ├── Delay.js
│ │ ├── Synth.js
│ │ └── Voice.js
│ ├── ui.js
│ └── user.js
└── lib
│ ├── bootstrap-3.3.7-dist
│ ├── css
│ │ ├── bootstrap-theme.css
│ │ ├── bootstrap-theme.css.map
│ │ ├── bootstrap-theme.min.css
│ │ ├── bootstrap-theme.min.css.map
│ │ ├── bootstrap.css
│ │ ├── bootstrap.css.map
│ │ ├── bootstrap.min.css
│ │ └── bootstrap.min.css.map
│ ├── fonts
│ │ ├── glyphicons-halflings-regular.eot
│ │ ├── glyphicons-halflings-regular.svg
│ │ ├── glyphicons-halflings-regular.ttf
│ │ ├── glyphicons-halflings-regular.woff
│ │ └── glyphicons-halflings-regular.woff2
│ └── js
│ │ ├── bootstrap.js
│ │ ├── bootstrap.min.js
│ │ └── npm.js
│ ├── decimal.js
│ ├── eventemitter3.js
│ ├── jquery-3.2.1.min.js
│ ├── jquery-3.2.1.min.map
│ ├── jquery-ui-1.12.1
│ ├── AUTHORS.txt
│ ├── LICENSE.txt
│ ├── external
│ │ └── jquery
│ │ │ └── jquery.js
│ ├── images
│ │ ├── ui-icons_444444_256x240.png
│ │ ├── ui-icons_555555_256x240.png
│ │ ├── ui-icons_777620_256x240.png
│ │ ├── ui-icons_777777_256x240.png
│ │ ├── ui-icons_cc0000_256x240.png
│ │ └── ui-icons_ffffff_256x240.png
│ ├── index.html
│ ├── jquery-ui.css
│ ├── jquery-ui.js
│ ├── jquery-ui.min.css
│ ├── jquery-ui.min.js
│ ├── jquery-ui.structure.css
│ ├── jquery-ui.structure.min.css
│ ├── jquery-ui.theme.css
│ ├── jquery-ui.theme.min.css
│ └── package.json
│ ├── jszip.min.js
│ ├── ramda-0.27.1.min.js
│ ├── socicon
│ ├── Read Me.txt
│ ├── demo-files
│ │ ├── demo.css
│ │ └── demo.js
│ ├── demo.html
│ ├── fonts
│ │ ├── Socicon.eot
│ │ ├── Socicon.svg
│ │ ├── Socicon.ttf
│ │ ├── Socicon.woff
│ │ └── Socicon.woff2
│ ├── selection.json
│ ├── style.css
│ ├── style.less
│ └── variables.less
│ ├── webmidi-3.0.19.iife.min.js
│ └── webmidi-3.0.19.iife.min.js.map
└── test.html
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | end_of_line = lf
5 | insert_final_newline = true
6 |
7 | [*.{js}]
8 | indent_style = space
9 | indent_size = 2
10 | quote_type = single
11 | max_line_length = 100
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "printWidth": 100,
4 | "singleQuote": true,
5 | "trailingComma": "none"
6 | }
7 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Scale Workshop
2 |
3 | 
4 |
5 |
6 | ## Description
7 |
8 | [Scale Workshop](http://sevish.com/scaleworkshop/) allows you to design microtonal scales and play them in your web browser. Export your scales for use with VST instruments. Convert Scala files to various tuning formats.
9 |
10 |
11 | ## Frequently Asked Questions
12 |
13 | ### What kinds of microtonal scales are possible with Scale Workshop?
14 |
15 | Scale Workshop can play any kind of microtonal scale, such as equal temperaments, just intonation, historical and traditional scales, non-octave scales, and any arbitrary tunings. The application offers a few methods to generate scales automatically based on parameters you set, or otherwise you can enter your scale data manually.
16 |
17 | ### Can I play and hear my scale?
18 |
19 | Yes, the built-in synth allows you to play your scales within the web browser. If your browser supports web MIDI then you can use a connected MIDI device to play notes. Otherwise you can use your computer keyboard (e.g. a QWERTY keyboard) as an isomorphic keyboard controller to hear your scales. You can also play on a touch device using the 'Touch Keyboard' feature.
20 |
21 | ### Can I use Scale Workshop to tune up other synths?
22 |
23 | Scale Workshop supports any synth that uses Scala (.scl/.kbm) files or AnaMark TUN (.tun) files. It can also export Native Instruments Kontakt tuning scripts, Max/MSP coll tuning tables and Pure Data text tuning tables.
24 |
25 | The Xen Wiki has a [list of microtonal software plugins](https://en.xen.wiki/w/List_of_Microtonal_Software_Plugins) that support Scala and AnaMark files.
26 |
27 | ### How do I enter scale/tuning data manually?
28 |
29 | Scale data should be entered in to the large text field labeled ‘Scale data’. Add each note on its own new line. Cents and ratios are both supported.
30 |
31 | * To specify a ratio, simply write it in the format e.g. `3/2`
32 | * To specify an interval in cents, include a . in the line e.g. `701.9` or `1200.`
33 | * To specify n steps out of m-EDO, write it in the format `n\m`
34 |
35 | No need to enter `0.` or `1/1` on the first line as your scale is automatically assumed to contain this interval.
36 |
37 | The interval on the final line is assumed to be your interval of equivalence (i.e. your octave or pseudo-octave).
38 |
39 | Don't add any other weird data to a line. Don't try to mix decimals with ratios (e.g. `2/1.5`). Scale Workshop will try to gracefully ignore any rubbish that you put in, but it's very possible that weird stuff will happen.
40 |
41 | ### Can I copy and paste the contents of a Scala file (.scl) directly into the 'Scale data' field?
42 |
43 | Scala files contain non-tuning related comments at the top of the file, so Scale Workshop will throw an error if you try to paste them in directly. Instead you can use the ‘Load .scl’ function, which automatically removes those comments for you. Or you can paste the Scala file but remove the comments manually.
44 |
45 | ### Can I convert a TUN file to another format?
46 |
47 | Yes, start by clicking New > Import .TUN and then load your TUN file into Scale Workshop. Then click Export and select your desired output format. Note that Scale Workshop is not a fully compliant AnaMark TUN v2 parser, however it should be able to read TUN files exported by Scala and Scale Workshop.
48 |
49 | ### How do I make my own keyboard mapping?
50 |
51 | Keyboard mappings are not currently supported. You can still export a Scala keyboard mapping file (.kbm) but it will assume a linear mapping.
52 | However you can always use duplicate lines in your tuning in order to skip any keys that you don't want to include, or write your .kbm file manually.
53 |
54 | ### Can I undo/redo?
55 |
56 | Use your browser's Back/Forward navigation buttons to undo/redo changes to your tuning.
57 |
58 | ### How can I share my tunings with a collaborator?
59 |
60 | Use Export > Share scale as URL. The given URL can be copied and pasted to another person. When they open the link they will see a Scale Workshop page with your scale already tuned in.
61 |
62 | ### How can I save my work for later?
63 |
64 | You can bookmark the current page to save your work for later. This works because your tuning data is stored within the bookmarked URL.
65 |
66 | ### When I export a tuning, I get a weird filename, why?
67 |
68 | Exporting a file with the correct filename is not supported in Safari (iOS and macOS). You can try to use Firefox, Chrome or Opera instead.
69 |
70 | ### Can I run this software offline?
71 |
72 | Yes, just download the project from GitHub as a zip file and run index.htm from your web browser.
73 |
74 | ### Can you add a new feature to Scale Workshop?
75 |
76 | Probably! Just add your feature request to the [issue tracker](https://github.com/SeanArchibald/scale-workshop/issues) on GitHub.
77 |
78 | ### I found a bug
79 |
80 | Please [create a bug report](https://github.com/SeanArchibald/scale-workshop/issues) detailing the steps to reproduce the issue. You should also include which web browser and OS you are using.
81 |
82 |
83 | ## Contributing
84 |
85 | Please base any work on develop branch, and pull requests should also be made against develop branch not master.
86 |
87 |
88 | ## Changelog
89 |
90 | ### 1.5
91 | * Feature: output MIDI for real-time microtuning in MIDI synths that support multichannel pitch bend
92 | * Feature: added button for toggling velocity sensing for MIDI in
93 | * Improvement: Virtual Keyboard now works better on desktop, can be closed with the Esc key, keys are highlighted when pressed
94 | * Improvement: user guide documentation is updated
95 | * Improvement: better decimal precision
96 | * Bug fix: typo in Kraig Grady Centaura Harmonic preset scale
97 | * Bug fix: enumerate chord inversion
98 | * Bug fix: Rotate modifier now preserves nonlinear scale data
99 |
100 | ### 1.4.2
101 | * New modifier: Rotate. This allows you to choose an interval from your scale to be the new 1/1. (Known issue: it doesn't work as expected for intervals with decimal notation e.g. `1,5`, `2,0`)
102 | * Bug fixed: synth notes stuck playing quietly in the background
103 |
104 | ### 1.4.1
105 | * Bug fixed: lag on Subset option
106 | * Bug fixed: audio drop out
107 | * Improvement: Graphical ruler shows currently loaded scale
108 |
109 | ### 1.4
110 | * New scale generator: Combination Product Set (CPS)
111 | * New scale modifier: Reduce
112 | * New scale modifier: Sort ascending
113 | * New keyboard keymap: Colemak DH
114 | * Bug fixed in TUN v1 file export (will improve compatibility with Serum, maybe others)
115 | * Improvement: More synth waveforms added
116 | * Improvement: More preset scales added
117 | * Improvement: Updates to the user guide
118 | * Improvement: Reaper Note Name Map provides more options
119 | * Improvement: Rank-2 scale generator will now preserve interval notation (e.g. it will return ratios if you input ratios, return cents if you input cents)
120 |
121 | ### 1.3.2
122 | * Many more preset scales added. Happy exploring!
123 |
124 | ### 1.3.1
125 | * AnaMark TUN export now supports a choice of v1 or v2 as different synths require a certain version.
126 |
127 | ### 1.3
128 | * AnaMark TUN export now contains v1 data only. This should improve compatibility with synths (e.g. Omnisphere and Quanta)
129 | * New feature: Export Korg Minilogue & Monologue tuning formats (.mnlgtuns & .mnlgtuno)
130 | * New feature: Export Soniccouture tuning format (.nka)
131 | * New feature: Export Reaper Note Name Map (.txt)
132 | * Bug fix: AnaMark TUN export is now v1 compliant - fixes compatibility with Spectrasonics Omnisphere
133 |
134 | ### 1.2
135 | * New feature: Approximate scale by harmonics of an arbitrary denominator
136 | * New feature: Approximate scale by subharmonics of an arbitrary numerator
137 | * New feature: Approximate scale to equal divisions
138 | * Improvement: 'Clear scale' function now moved into 'New' menu
139 | * Improvement: 'Mode' renamed to 'Subset'
140 | * Improvement: Various updates to the user guide
141 | * Bug fix: 'Stretch/compress' now works as it should
142 | * Bug fix: 'Tempo-sync beating' now works as it should
143 |
144 | ### 1.1.1
145 | * Improvement: When sharing a Scale Workshop link (on Discord, Facebook, etc.) the site description is now much shorter so takes less space
146 |
147 | ### 1.1
148 | * New feature: Export tuning files for Harmor and Sytrus synths (thanks to Azorlogh)
149 | * Improvement: 'Mode' feature now shows a counter while you input a subset
150 | * Improvement: Include scale URL in a comment within exported TUN, scl, Max/MSP txt and Kontakt script exports (issue #66)
151 |
152 | ### 1.0.4
153 | * New feature: 'Approximate' method for modifying scales can produce rational approximations of your scale
154 | * Improvement: Enumerate Chord method of scale generation now allows for inverted chords (e.g. 4:5:6 inverted would give 10:12:15)
155 | * Improvement: site now automatically redirects to HTTPS on domains known to have valid HTTPS
156 | * Bug fix: changing the main volume before pressing the first note no longer gets ignored
157 | * Bug fix: exported TUN files now has a correct functional tuning section (https://github.com/SeanArchibald/scale-workshop/issues/82)
158 |
159 | ### 1.0.3
160 | * New feature: generate scale from 'Enumerate chord' e.g. `4:5:6:7:8` will result in a scale containing intervals 1/1, 5/4, 3/2, 7/4, 2/1
161 | * New feature: specify an interval in decimal format e.g. `1,5` for a perfect fifth, `2,0` for an octave.
162 | * New feature: export your tuning as a list of Deflemask 'fine tune' effect parameters. The resulting text file is a reference you can use when manually inputting notes into Deflemask chip music tracker.
163 | * Improvement: Colemak keyboard layout support added
164 | * Improvement: when generating rank-2 temperaments, finding MOS scale sizes is now more efficient.
165 | * Bug fix: error when changing main volume before audio initialised
166 |
167 | ### 1.0.2
168 | * MIDI now waits for user input before initializing (issues #56 #57)
169 | * Rank-2 temperament generator now assumes you want all positive/up generators by default (issue #58)
170 |
171 | ### 1.0.1
172 | * Fix stuck notes during MIDI note input
173 | * Fix stuck notes when playing pad synth in Firefox/Safari
174 |
175 | ### 1.0.0
176 | * Stable version
177 | * New modifier added: tempo-sync beating
178 | * Minor bug fixes
179 |
180 | ### 0.9.9
181 | * Added a selection of preset scales
182 | * Fix issue using delay in some situations
183 | * Fix issue stretching/compressing scales in some situations
184 | * Minor interface and user guide improvements
185 |
186 | ### 0.9.8
187 | * Fix .scl import bug
188 |
189 | ### 0.9.7
190 | * Added user guide
191 | * Fix `n\m` style data input
192 |
193 | ### 0.9.6
194 | * Improved modal dialogs on mobile
195 | * Fix regression exporting .tun files
196 |
197 | ### 0.9.5
198 | * Loading the synth is now delayed as much as possible
199 | * Better compatibility for exported Scala files (placeholder description will be used if user doesn't provide a tuning description)
200 | * Improved mode input - you can optionally enter a list of scale degrees from the base note (e.g. 2 4 5 7 9 11 12)
201 | * Stricter validation of tuning data input, improves security
202 | * More default/auto keyboard colour layouts added
203 |
204 | ### 0.9.4
205 | * Import AnaMark .tun files (NOT compliant to the AnaMark v2 spec, but should import tun files generated by Scala and Scale Workshop)
206 | * Dvorak and Programmer Dvorak keyboard layouts are now supported
207 | * Code refactoring and improvements
208 | * Fix: Scale Workshop will no longer prevent keyboard shortcuts from being used
209 |
210 | ### 0.9.3
211 | * Undo/redo function (via browser back/forward navigation)
212 | * Various UI improvements, mostly for phone-sized devices
213 | * Code refactoring and improvements (thanks Lajos)
214 |
215 | ### 0.9.2
216 | * Added key colour customisation
217 | * Added 'About Scale Workshop' screen
218 | * When sharing scale by URL, key colour layout and synth options will now carry across
219 | * When using a menu option that opens a modal dialog, the first field will automatically be selected
220 | * Choice of regional keyboard layout is now remembered across sessions
221 | * Delay time control now shows milliseconds value
222 |
223 | ### 0.9.1
224 | * Improved rank-2 temperament generator. You can now specify how many generators up or down from 1/1
225 |
226 | ### 0.9
227 | * Added virtual keyboard for touch interfaces (experimental)
228 |
229 | ### 0.8.9
230 | * Improved workflow ('Calculate' button removed as the app now responds to scale data changes automatically)
231 | * Improved no-javascript error message
232 | * Fix: Scala .scl file export now preserves ratios instead of converting them to cents
233 |
234 | ### 0.8.8
235 | * Fix stuck notes in Mozilla Firefox (due to differing implementations of the Web Audio API between web browsers, the amplitude envelopes are going to sound slightly different in Firefox)
236 | * Fix blank option shown in 'Line endings format' when using Scale Workshop for the first time
237 | * Fix styling issue with light theme when hovering over top menu option
238 |
239 | ### 0.8.7
240 |
241 | * Basic MIDI input support
242 | * General Settings are now automatically saved and restored across sessions
243 | * Added "Night Mode" dark theme for late night sessions in the workshop
244 | * Added user.js file where you can add your own custom script if needed
245 |
246 | ### 0.8.6
247 |
248 | * Added info tooltips
249 | * URL fix for Xenharmonic Wiki
250 |
251 | ### 0.8.5
252 |
253 | * Added amplitude envelope for synth notes (organ, pad, and percussive presets)
254 | * Added main volume control
255 | * Added keyboard layout setting for international keyboards (English and Hungarian supported)
256 |
257 | ### 0.8.4
258 |
259 | * Added delay effect
260 | * Added 'auto' function for base frequency, which calculates the frequency for the specified MIDI note number assuming 12-EDO A440
261 | * Added option to choose between Microsoft/Unix line endings
262 | * Added indicator to show when Qwerty isomorphic keyboard is active (when typing in a text field, it is inactive)
263 | * Added 'Quiet' button in case things get noisy in the workshop
264 | * Added share scale as URL to email, twitter
265 | * Fix sharing scale as URL - isomorphic mapping
266 | * Removed debug option - debug messages will now be output to the JavaScript console by default. Use `debug = false;` in console to disable
267 | * Improved options menu - options instantly take effect when changed (removed Apply/Save button)
268 |
269 | ### 0.8.3
270 |
271 | * Fix sharing scale as URL - now the qwerty isomorphic mapping is correctly shared
272 |
273 | ### 0.8.2
274 |
275 | * Settings have been moved to the right-side column (desktop)
276 | * Added option to export a list of frequencies in text format readable by Pure Data's [text] object
277 |
278 | ### 0.8.1
279 |
280 | * Choice of waveform for the synth: triangle, sawtooth, square, sine
281 | * Settings menus added - General, Synth and Note Input settings
282 | * Qwerty isomorphic keyboard mapping can be changed in the Note Input settings
283 | * Qwerty isomorphic keyboard mapping is saved when sharing scale by URL
284 | * Currently displayed notes are now highlighted in the tuning data table
285 | * Fix stuck note in FireFox when pressing `/` key
286 | * UI improvement (for large screens): tall columns are now contained within one window and individually scrollable
287 | * Tuning data table is now displayed more compactly to show more info at once
288 |
289 | ### 0.8
290 |
291 | * Synth added: use the QWERTY keys to play current scale
292 | * Export a scale as a URL with the 'Share scale as URL' option
293 |
294 | ### 0.7.1
295 |
296 | * Fix missing line breaks on Notepad and some other text editors
297 | * Improved readme formatting (thanks suhr!)
298 |
299 | ### 0.7.0
300 |
301 | * Scale modifiers added: ‘stretch’, ‘random variance’, ‘mode’
302 | * Users can now input `n\m` to specify n steps out of m-EDO
303 | * When generating a rank-2 temperament, display the scale sizes which are MOS
304 | * Improve UI for user input, using custom modals instead of JS prompts
305 | * Code refactored to reduce the amount of duplication
306 | * Code is now split up over various js files so it's easier to navigate
307 | * Change logo/favicon to square shape
308 |
309 | ### 0.6
310 |
311 | * Generate rank-2 temperaments
312 |
313 | ### 0.5
314 |
315 | * Fix incorrect base frequency when exporting TUN format and NI Kontakt format
316 | * Export Scala .kbm format
317 |
318 | ### 0.4
319 |
320 | * All dependencies (Bootstrap, jQuery etc.) now included in scaleworkshop directory
321 | * Import Scala .scl format
322 | * Export Scala .scl format
323 | * Export AnaMark TUN format
324 | * Export Native Instruments Kontakt script format
325 | * Export Max/MSP coll format
326 |
327 | ### 0.3
328 |
329 | * Generate equal-tempered tuning
330 | * Generate harmonic series segment tuning
331 | * Generate subharmonic series segment tuning
332 |
333 | ### 0.2
334 |
335 | * Allow tuning data input to be parsed into a frequency table
336 |
337 | ### 0.1
338 |
339 | * Initial version
340 |
341 |
342 | ## Contributors
343 |
344 | * Sevish
345 | * Scott Thompson
346 | * Lajos Mészáros
347 | * Carl Lumma
348 | * Tobia
349 | * Vincenzo Sicurella
350 | * Azorlogh
351 |
352 |
353 | ## License
354 |
355 | Copyright (c) 2017-2019 Sean Archibald
356 |
357 | Permission is hereby granted, free of charge, to any person obtaining a copy
358 | of this software and associated documentation files (the "Software"), to deal
359 | in the Software without restriction, including without limitation the rights
360 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
361 | copies of the Software, and to permit persons to whom the Software is
362 | furnished to do so, subject to the following conditions:
363 |
364 | The above copyright notice and this permission notice shall be included in all
365 | copies or substantial portions of the Software.
366 |
367 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
368 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
369 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
370 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
371 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
372 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
373 | SOFTWARE.
374 |
--------------------------------------------------------------------------------
/dev/docs/stack.md:
--------------------------------------------------------------------------------
1 | # Stack
2 |
3 | ## Client
4 |
5 | - Bootstrap 3.3.7 - https://getbootstrap.com/docs/3.3/
6 | - jQuery 3.2.1 - https://api.jquery.com/
7 | - jQuery UI 1.12.1 - https://jqueryui.com/
8 | - Ramda 0.27.1 - https://ramdajs.com/docs/
9 | - JSZip 3.2.1 - http://stuartk.com/jszip
10 |
11 | ## Testing
12 |
13 | - Mocha - https://mochajs.org/
14 | - Expect - https://jestjs.io/docs/expect
15 |
--------------------------------------------------------------------------------
/src/assets/favicon/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/favicon/android-chrome-192x192.png
--------------------------------------------------------------------------------
/src/assets/favicon/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/favicon/android-chrome-512x512.png
--------------------------------------------------------------------------------
/src/assets/favicon/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/favicon/apple-touch-icon.png
--------------------------------------------------------------------------------
/src/assets/favicon/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #00a300
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/assets/favicon/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/favicon/favicon-16x16.png
--------------------------------------------------------------------------------
/src/assets/favicon/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/favicon/favicon-32x32.png
--------------------------------------------------------------------------------
/src/assets/favicon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/favicon/favicon.ico
--------------------------------------------------------------------------------
/src/assets/favicon/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "icons": [
4 | {
5 | "src": "/android-chrome-192x192.png",
6 | "sizes": "192x192",
7 | "type": "image/png"
8 | },
9 | {
10 | "src": "/android-chrome-512x512.png",
11 | "sizes": "512x512",
12 | "type": "image/png"
13 | }
14 | ],
15 | "theme_color": "#ffffff",
16 | "background_color": "#ffffff",
17 | "display": "standalone"
18 | }
--------------------------------------------------------------------------------
/src/assets/favicon/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/favicon/mstile-150x150.png
--------------------------------------------------------------------------------
/src/assets/favicon/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/img/guide/cps.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/cps.png
--------------------------------------------------------------------------------
/src/assets/img/guide/dark-mode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/dark-mode.png
--------------------------------------------------------------------------------
/src/assets/img/guide/enumerate-chord.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/enumerate-chord.png
--------------------------------------------------------------------------------
/src/assets/img/guide/equal-temperament.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/equal-temperament.png
--------------------------------------------------------------------------------
/src/assets/img/guide/general-settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/general-settings.png
--------------------------------------------------------------------------------
/src/assets/img/guide/harmonic-series.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/harmonic-series.png
--------------------------------------------------------------------------------
/src/assets/img/guide/isomorphic-settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/isomorphic-settings.png
--------------------------------------------------------------------------------
/src/assets/img/guide/maxmsp-coll.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/maxmsp-coll.png
--------------------------------------------------------------------------------
/src/assets/img/guide/menu-export.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/menu-export.png
--------------------------------------------------------------------------------
/src/assets/img/guide/menu-modify.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/menu-modify.png
--------------------------------------------------------------------------------
/src/assets/img/guide/menu-new.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/menu-new.png
--------------------------------------------------------------------------------
/src/assets/img/guide/midi-io-settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/midi-io-settings.png
--------------------------------------------------------------------------------
/src/assets/img/guide/preset-scales.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/preset-scales.png
--------------------------------------------------------------------------------
/src/assets/img/guide/qwerty-disabled.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/qwerty-disabled.png
--------------------------------------------------------------------------------
/src/assets/img/guide/qwerty-enabled.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/qwerty-enabled.png
--------------------------------------------------------------------------------
/src/assets/img/guide/random-variance.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/random-variance.png
--------------------------------------------------------------------------------
/src/assets/img/guide/rank-2-temperament.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/rank-2-temperament.png
--------------------------------------------------------------------------------
/src/assets/img/guide/reaper-named-notes.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/reaper-named-notes.png
--------------------------------------------------------------------------------
/src/assets/img/guide/scale-entry.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/scale-entry.png
--------------------------------------------------------------------------------
/src/assets/img/guide/stretch-compress.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/stretch-compress.png
--------------------------------------------------------------------------------
/src/assets/img/guide/subharmonic-series.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/subharmonic-series.png
--------------------------------------------------------------------------------
/src/assets/img/guide/subset.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/subset.png
--------------------------------------------------------------------------------
/src/assets/img/guide/synth-settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/synth-settings.png
--------------------------------------------------------------------------------
/src/assets/img/guide/tempo-sync-beating.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/tempo-sync-beating.png
--------------------------------------------------------------------------------
/src/assets/img/guide/virtual-keyboard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/guide/virtual-keyboard.png
--------------------------------------------------------------------------------
/src/assets/img/scale-workshop-og-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/assets/img/scale-workshop-og-image.png
--------------------------------------------------------------------------------
/src/css/style-dark.css:
--------------------------------------------------------------------------------
1 | /* "Night Mode" dark theme styles */
2 |
3 | body.dark {
4 | background-color: #000;
5 | color: #bbb;
6 | }
7 |
8 | .dark p, .dark label {
9 | color: #bbb;
10 | }
11 |
12 | .dark hr {
13 | border-color: #222;
14 | }
15 |
16 | .dark .ui-tooltip {
17 | background-color: #000;
18 | }
19 |
20 | .dark #header-mobile {
21 | background-color: #111;
22 | }
23 |
24 | .dark canvas {
25 | filter: invert(1);
26 | }
27 |
28 | /* Interactable elements */
29 |
30 | .dark .navbar button, .dark .navbar-inverse .navbar-toggle:focus, .dark .navbar-inverse .navbar-toggle:hover, .dark .navbar-collapse {
31 | background-color: #111;
32 | }
33 |
34 | .dark input:not(.btn), .dark textarea, .dark select {
35 | background-color: #222;
36 | color: #bbb;
37 | border-color: #888;
38 | }
39 |
40 | .dark .input-group-addon {
41 | background-color: #000;
42 | color: #bbb;
43 | }
44 |
45 | .dark .btn-default, .dark .ui-button {
46 | background-color: #222;
47 | color: #bbb;
48 | }
49 |
50 | /* Accordion menu */
51 |
52 | .dark .ui-accordion-header {
53 | background-color: #222;
54 | color: #bbb;
55 | }
56 |
57 | .dark .ui-accordion-content {
58 | background-color: #000;
59 | color: #bbb;
60 | }
61 |
62 | /* Navbar menu */
63 |
64 | .dark ul.dropdown-menu {
65 | background-color: #000;
66 | border: 1px solid #444;
67 | border-top: none;
68 | }
69 |
70 | .dark .dropdown-menu > li > a {
71 | color: #bbb;
72 | }
73 |
74 | .dark .dropdown-menu>li>a:focus, .dark .dropdown-menu>li>a:hover {
75 | background-color: #222;
76 | }
77 |
78 | .dark .dropdown-menu .divider {
79 | background-color: #222;
80 | }
81 |
82 | .dark .ui-widget-overlay {
83 | background: black;
84 | }
85 |
86 | /* Modal dialogs */
87 |
88 | .dark .ui-widget-content {
89 | color: #bbb;
90 | }
91 |
92 | .dark .ui-dialog a {
93 | color: #ddd;
94 | }
95 |
96 | .dark .ui-dialog {
97 | box-shadow: #444 0px 0px 70px;
98 | }
99 |
100 | .dark .ui-dialog, .dark .ui-dialog-titlebar {
101 | background-color: #000;
102 | color: #bbb;
103 | border: none;
104 | border-radius: 0px;
105 | }
106 |
107 | .dark .ui-dialog-titlebar {
108 | border-bottom: 1px solid #888;
109 | }
110 |
111 | .dark button.ui-button.ui-corner-all.ui-widget.ui-button-icon-only.ui-dialog-titlebar-close { /* modal close buttons */
112 | background: #000;
113 | border: none;
114 | }
115 |
116 | .dark .ui-dialog-buttonpane {
117 | background: #000;
118 | border: none;
119 | }
120 |
121 | .dark .socicon-mail {
122 | color: #bbb;
123 | }
124 |
125 | /* Virtual Keyboard */
126 |
127 | .dark #virtual-keyboard {
128 | background-color: black;
129 | }
130 | .dark #virtual-keyboard td {
131 | border: 1px solid grey;
132 | }
133 |
134 | /* Tuning Table */
135 |
136 | .dark #tuning-table th, .dark #tuning-table td {
137 | border-color: #333;
138 | }
139 |
140 | .dark #tuning-table th:hover, .dark #tuning-table tr:hover {
141 | background-color: #222;
142 | }
143 |
144 | .dark #tuning-table tr.warning td {
145 | background-color: #192d37; /*#fcf8e3*/
146 | }
147 |
148 | .dark #tuning-table tr.info td {
149 | background-color: #4c4823; /*#d9edf7*/
150 | }
151 |
152 | .dark tr.bg-playnote td {
153 | background-color: #1f4018 !important; /*#dff0d8*/
154 | }
155 |
156 | /*
157 | * NON-MOBILE
158 | */
159 | @media (min-width: 768px) {
160 |
161 | .dark .navbar {
162 | background-color: #111;
163 | }
164 |
165 | }
166 | @media (min-width: 992px) {
167 |
168 |
169 |
170 | }
171 | @media (max-width: 991px) {
172 |
173 |
174 |
175 | }
176 |
--------------------------------------------------------------------------------
/src/css/style.css:
--------------------------------------------------------------------------------
1 | img,
2 | canvas {
3 | max-width: 100%;
4 | }
5 |
6 | .helpicon {
7 | color: #999;
8 | font-size: 0.9em;
9 | }
10 |
11 | .hidden {
12 | display: none;
13 | }
14 |
15 | .ui-widget-overlay {
16 | position: absolute;
17 | top: 0;
18 | left: 0;
19 | width: 100%;
20 | height: 100%;
21 | background: white;
22 | opacity: 0.5;
23 | }
24 |
25 | /* Make jQuery UI colors match with Bootstrap */
26 | .ui-state-default,
27 | .ui-widget-content .ui-state-default,
28 | .ui-widget-header .ui-state-default,
29 | .ui-button,
30 | html .ui-button.ui-state-disabled:hover,
31 | html .ui-button.ui-state-disabled:active {
32 | color: black;
33 | background-color: #eee;
34 | border-color: #ccc;
35 | }
36 | .ui-state-active,
37 | .ui-widget-content .ui-state-active,
38 | .ui-widget-header .ui-state-active,
39 | a.ui-button:active,
40 | .ui-button:active,
41 | .ui-button.ui-state-active:hover {
42 | color: black;
43 | background-color: #eee;
44 | border-color: #ccc;
45 | }
46 |
47 | h3.ui-accordion-header {
48 | font-weight: bold;
49 | }
50 |
51 | textarea {
52 | resize: vertical;
53 | }
54 |
55 | /* normal cursor when hovering over navbar */
56 | .navbar a {
57 | cursor: default;
58 | }
59 |
60 | .navbar {
61 | z-index: 4;
62 | position: fixed;
63 | left: 0px;
64 | top: 0px;
65 | border: none;
66 | width: 100%;
67 | }
68 |
69 | .navbar button {
70 | margin: 0px;
71 | border-radius: unset;
72 | border: none;
73 | background-color: #333;
74 | width: 50px;
75 | }
76 |
77 | .navbar-toggle .icon-bar {
78 | width: 30px;
79 | }
80 |
81 | .navbar-inverse {
82 | background-color: unset;
83 | }
84 |
85 | .navbar-toggle {
86 | padding: 10px;
87 | }
88 |
89 | .navbar-toggle .icon-bar + .icon-bar {
90 | margin-top: 12px;
91 | }
92 |
93 | .navbar-header {
94 | padding-left: 0px !important;
95 | }
96 |
97 | .navbar-collapse {
98 | background-color: #333;
99 | border: none;
100 | width: calc(100vw - 50px);
101 | position: fixed;
102 | top: 0px;
103 | max-height: 100vh;
104 | }
105 |
106 | .navbar-brand {
107 | display: none;
108 | cursor: default;
109 | }
110 |
111 | .nav > li {
112 | width: 49%;
113 | display: inline-block;
114 | }
115 |
116 | .nav > li:hover,
117 | .navbar-brand:hover {
118 | background-color: #222;
119 | }
120 |
121 | .nav > li.open {
122 | width: 100%;
123 | }
124 |
125 | #header-mobile {
126 | background-color: #333;
127 | width: 100%;
128 | height: 50px;
129 | margin-top: -20px;
130 | }
131 |
132 | #header-mobile h1 {
133 | font-size: 12pt;
134 | font-weight: normal;
135 | color: white;
136 | padding: 17px;
137 | }
138 |
139 | body > .container-fluid > .row {
140 | margin-top: 20px;
141 | }
142 |
143 | input#btn_frequency_auto,
144 | input#btn_key_colors_auto {
145 | margin-top: 3px;
146 | }
147 |
148 | #txt_name {
149 | font-size: 1.4em;
150 | background-color: unset;
151 | }
152 |
153 | #col-tuning-table {
154 | padding-left: 0px;
155 | padding-right: 0px;
156 | }
157 |
158 | #tuning-table {
159 | margin-bottom: 4px;
160 | }
161 |
162 | .table-condensed > tbody > tr > td,
163 | .table-condensed > tbody > tr > th,
164 | .table-condensed > tfoot > tr > td,
165 | .table-condensed > tfoot > tr > th,
166 | .table-condensed > thead > tr > td,
167 | .table-condensed > thead > tr > th {
168 | padding: 3px 5px;
169 | }
170 |
171 | tr.bg-playnote td {
172 | background-color: #dff0d8 !important;
173 | }
174 |
175 | #tuning-table td,
176 | #tuning-table th {
177 | text-align: center;
178 | }
179 |
180 | p.social-icons {
181 | text-align: center;
182 | font-size: 1.5em;
183 | }
184 | .social-icons .socicon-twitter {
185 | color: #4da7de;
186 | }
187 |
188 | div#qwerty-indicator {
189 | padding: 1em;
190 | display: none;
191 | }
192 |
193 | #btn_panic {
194 | display: none;
195 | }
196 |
197 | div#splash {
198 | position: absolute;
199 | top: 0;
200 | left: 0;
201 | width: 100vw;
202 | height: 100vh;
203 | background-color: white;
204 | z-index: 10;
205 | display: table;
206 | }
207 |
208 | div#splash-center {
209 | display: table-cell;
210 | vertical-align: middle;
211 | text-align: center;
212 | }
213 | div#splash-center img {
214 | max-width: 70vw;
215 | height: auto;
216 | box-shadow: #aaa 0px 0px 40px;
217 | }
218 |
219 | #modal_load_preset_scale optgroup + optgroup {
220 | margin-top: 1em;
221 | }
222 |
223 | /* Virtual keyboard */
224 |
225 | #virtual-keyboard {
226 | background-color: white;
227 | position: fixed;
228 | top: 50px;
229 | left: 0;
230 | width: 100vw;
231 | min-width: 500px; /* this stops the keys getting too close together for portrait mobile users */
232 | height: calc(100vh - 50px);
233 | display: none;
234 | z-index: 2;
235 | }
236 | #virtual-keyboard td {
237 | text-align: center;
238 | vertical-align: middle;
239 | border: 1px solid grey;
240 | font-size: 0.6em;
241 | user-select: none;
242 | cursor: pointer;
243 | }
244 | #virtual-keyboard td p {
245 | pointer-events: none;
246 | word-break: break-word;
247 | line-height: 1.1em;
248 | color: #888;
249 | }
250 |
251 | #virtual-keyboard .key:hover {
252 | background: linear-gradient(
253 | 0deg,
254 | rgba(255, 255, 255, 0) 0%,
255 | rgba(255, 0, 0, 0.5) 50%,
256 | rgba(255, 255, 255, 0) 100%
257 | );
258 | }
259 | #virtual-keyboard .key.active {
260 | background: linear-gradient(
261 | 0deg,
262 | rgba(0, 0, 0, 0) 0%,
263 | rgba(0, 255, 0, 0.5) 50%,
264 | rgba(0, 0, 0, 0) 100%
265 | );
266 | }
267 |
268 | /*
269 | * Fullscreen variant for jQueryUI modal widget
270 | */
271 | .fullscreen-modal {
272 | top: 0px !important;
273 | left: 0px !important;
274 | width: 100vw !important;
275 | height: 100vh !important;
276 | position: fixed;
277 | }
278 | .fullscreen-modal .ui-dialog-buttonpane {
279 | }
280 |
281 | /*
282 | * JQUERY MODAL UI MOBILE-ONLY FIXES
283 | */
284 | @media (max-width: 420px), /* OR */ (max-height: 420px) {
285 | .ui-dialog {
286 | top: 0px !important;
287 | left: 0px !important;
288 | width: 100vw !important;
289 | max-height: 100vh !important;
290 | position: fixed;
291 | overflow-x: scroll;
292 | }
293 | }
294 |
295 | /*
296 | * NON-MOBILE
297 | */
298 | @media (min-width: 768px) {
299 | body > .container-fluid > .row {
300 | margin-top: 0px;
301 | }
302 |
303 | div#qwerty-indicator {
304 | display: block;
305 | }
306 |
307 | .col-main {
308 | /* main columns of the Scale Workshop UI */
309 | height: calc(100vh - 70px);
310 | overflow-y: auto;
311 | }
312 |
313 | #btn_panic {
314 | display: unset;
315 | }
316 |
317 | #virtual-keyboard {
318 | font-size: 0.9em;
319 | height: calc(100vh - 50px);
320 | }
321 |
322 | #tuning-table td.key-color,
323 | #tuning-table th.key-color {
324 | border-left: 1px solid #ddd;
325 | }
326 |
327 | .navbar {
328 | border-radius: 0px;
329 | }
330 |
331 | .navbar {
332 | z-index: 4;
333 | position: relative;
334 | left: unset;
335 | top: unset;
336 | background-color: #222;
337 | border: none;
338 | }
339 |
340 | .navbar-header {
341 | padding-left: 0px !important;
342 | }
343 |
344 | .navbar-collapse {
345 | background-color: unset;
346 | position: unset;
347 | }
348 |
349 | .navbar-brand {
350 | display: block;
351 | }
352 |
353 | .nav > li {
354 | width: unset;
355 | display: block;
356 | }
357 |
358 | .nav > li.open {
359 | width: unset;
360 | }
361 | }
362 | @media (min-width: 992px) {
363 | .col-sub {
364 | /* main columns of the Scale Workshop UI */
365 | height: calc(100vh - 70px);
366 | overflow-y: auto;
367 | }
368 |
369 | .navbar {
370 | border-radius: 0px;
371 | }
372 | }
373 | @media (max-width: 991px) {
374 | #col-tuning-table {
375 | margin-top: 1em;
376 | padding-left: 0px;
377 | padding-right: 0px;
378 | }
379 | }
380 |
381 | #modal_midi_settings {
382 | user-select: none;
383 | }
384 | #modal_midi_settings .form-group {
385 | margin-top: 20px;
386 | }
387 | #modal_midi_settings .settings .row {
388 | display: flex;
389 | margin: 0;
390 | }
391 | #modal_midi_settings .device {
392 | display: flex;
393 | align-items: center;
394 | }
395 | #modal_midi_settings .checkbox-wrapper {
396 | padding: 0 10px;
397 | }
398 | #modal_midi_settings .device input[type='checkbox'] {
399 | margin: 0;
400 | }
401 | #modal_midi_settings .device h4 {
402 | flex-grow: 1;
403 | font-size: unset;
404 | }
405 | #modal_midi_settings .device h4 label {
406 | margin: 0;
407 | font-weight: unset;
408 | }
409 | #modal_midi_settings .channels {
410 | display: flex;
411 | flex-wrap: wrap;
412 | }
413 | #modal_midi_settings .channels label, #modal_midi_settings .settings label {
414 | font-weight: unset;
415 | }
416 | #modal_midi_settings .device + .device {
417 | margin-top: 2em;
418 | }
419 | #modal_midi_settings .channel {
420 | display: flex;
421 | flex-direction: column;
422 | padding: 0 10px;
423 | align-items: center;
424 | }
425 | #modal_midi_settings .channel input[type='checkbox'] {
426 | margin: 0;
427 | }
428 |
--------------------------------------------------------------------------------
/src/js/constants.js:
--------------------------------------------------------------------------------
1 | const LINE_TYPE = {
2 | CENTS: 'cents',
3 | DECIMAL: 'decimal',
4 | RATIO: 'ratio',
5 | N_OF_EDO: 'n of edo',
6 | INVALID: 'invalid'
7 | }
8 |
9 | const SEMITONE_RATIO_IN_12_EDO = Math.pow(2, 1 / 12)
10 |
11 | const MNLG_OCTAVESIZE = 12
12 | const MNLG_SCALESIZE = 128
13 | const MNLG_MAXCENTS = 12800
14 | const MNLG_A_REF = { val: 6900, ind: 69, freq: 440.0 }
15 | const MNLG_C_REF = { val: 6000, ind: 60, freq: 261.6255653 }
16 |
17 | // prettier-ignore
18 | const PRIMES = [
19 | 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97,
20 | 101, 103, 107, 109, 113, 127, 131, 137, 139, 149, 151, 157, 163, 167, 173, 179, 181, 191, 193, 197, 199,
21 | 211, 223, 227, 229, 233, 239, 241, 251, 257, 263, 269, 271, 277, 281, 283, 293,
22 | 307, 311, 313, 317, 331, 337, 347, 349, 353, 359, 367, 373, 379, 383, 389, 397,
23 | 401, 409, 419, 421, 431, 433, 439, 443, 449, 457, 461, 463, 467, 479, 487, 491, 499,
24 | 503, 509, 521, 523, 541, 547, 557, 563, 569, 571, 577, 587, 593, 599,
25 | 601, 607, 613, 617, 619, 631, 641, 643, 647, 653, 659, 661, 673, 677, 683, 691,
26 | 701, 709, 719, 727, 733, 739, 743, 751, 757, 761, 769, 773, 787, 797,
27 | 809, 811, 821, 823, 827, 829, 839, 853, 857, 859, 863, 877, 881, 883, 887,
28 | 907, 911, 919, 929, 937, 941, 947, 953, 967, 971, 977, 983, 991, 997,
29 | 1009, 1013, 1019, 1021, 1031, 1033, 1039, 1049, 1051, 1061, 1063, 1069, 1087, 1091, 1093, 1097,
30 | 1103, 1109, 1117, 1123, 1129, 1151, 1153, 1163, 1171, 1181, 1187, 1193,
31 | 1201, 1213, 1217, 1223, 1229, 1231, 1237, 1249, 1259, 1277, 1279, 1283, 1289, 1291, 1297,
32 | 1301, 1303, 1307, 1319, 1321, 1327, 1361, 1367, 1373, 1381, 1399,
33 | 1409, 1423, 1427, 1429, 1433, 1439, 1447, 1451, 1453, 1459, 1471, 1481, 1483, 1487, 1489, 1493, 1499,
34 | 1511, 1523, 1531, 1543, 1549, 1553, 1559, 1567, 1571, 1579, 1583, 1597,
35 | 1601, 1607, 1609, 1613, 1619, 1621, 1627, 1637, 1657, 1663, 1667, 1669, 1693, 1697, 1699,
36 | 1709, 1721, 1723, 1733, 1741, 1747, 1753, 1759, 1777, 1783, 1787, 1789,
37 | 1801, 1811, 1823, 1831, 1847, 1861, 1867, 1871, 1873, 1877, 1879, 1889,
38 | 1901, 1907, 1913, 1931, 1933, 1949, 1951, 1973, 1979, 1987, 1993, 1997, 1999,
39 | 2003, 2011, 2017, 2027, 2029, 2039, 2053, 2063, 2069, 2081, 2083, 2087, 2089, 2099,
40 | 2111, 2113, 2129, 2131, 2137, 2141, 2143, 2153, 2161, 2179,
41 | 2203, 2207, 2213, 2221, 2237, 2239, 2243, 2251, 2267, 2269, 2273, 2281, 2287, 2293, 2297,
42 | 2309, 2311, 2333, 2339, 2341, 2347, 2351, 2357, 2371, 2377, 2381, 2383, 2389, 2393, 2399,
43 | 2411, 2417, 2423, 2437, 2441, 2447, 2459, 2467, 2473, 2477,
44 | 2503, 2521, 2531, 2539, 2543, 2549, 2551, 2557, 2579, 2591, 2593,
45 | 2609, 2617, 2621, 2633, 2647, 2657, 2659, 2663, 2671, 2677, 2683, 2687, 2689, 2693, 2699,
46 | 2707, 2711, 2713, 2719, 2729, 2731, 2741, 2749, 2753, 2767, 2777, 2789, 2791, 2797,
47 | 2801, 2803, 2819, 2833, 2837, 2843, 2851, 2857, 2861, 2879, 2887, 2897,
48 | 2903, 2909, 2917, 2927, 2939, 2953, 2957, 2963, 2969, 2971, 2999,
49 | 3001, 3011, 3019, 3023, 3037, 3041, 3049, 3061, 3067, 3079, 3083, 3089,
50 | 3109, 3119, 3121, 3137, 3163, 3167, 3169, 3181, 3187, 3191,
51 | 3203, 3209, 3217, 3221, 3229, 3251, 3253, 3257, 3259, 3271, 3299,
52 | 3301, 3307, 3313, 3319, 3323, 3329, 3331, 3343, 3347, 3359, 3361, 3371, 3373, 3389, 3391,
53 | 3407, 3413, 3433, 3449, 3457, 3461, 3463, 3467, 3469, 3491, 3499,
54 | 3511, 3517, 3527, 3529, 3533, 3539, 3541, 3547, 3557, 3559, 3571, 3581, 3583, 3593,
55 | 3607, 3613, 3617, 3623, 3631, 3637, 3643, 3659, 3671, 3673, 3677, 3691, 3697,
56 | 3701, 3709, 3719, 3727, 3733, 3739, 3761, 3767, 3769, 3779, 3793, 3797,
57 | 3803, 3821, 3823, 3833, 3847, 3851, 3853, 3863, 3877, 3881, 3889,
58 | 3907, 3911, 3917, 3919, 3923, 3929, 3931, 3943, 3947, 3967, 3989,
59 | 4001, 4003, 4007, 4013, 4019, 4021, 4027, 4049, 4051, 4057, 4073, 4079, 4091, 4093, 4099,
60 | 4111, 4127, 4129, 4133, 4139, 4153, 4157, 4159, 4177,
61 | 4201, 4211, 4217, 4219, 4229, 4231, 4241, 4243, 4253, 4259, 4261, 4271, 4273, 4283, 4289, 4297,
62 | 4327, 4337, 4339, 4349, 4357, 4363, 4373, 4391, 4397,
63 | 4409, 4421, 4423, 4441, 4447, 4451, 4457, 4463, 4481, 4483, 4493,
64 | 4507, 4513, 4517, 4519, 4523, 4547, 4549, 4561, 4567, 4583, 4591, 4597,
65 | 4603, 4621, 4637, 4639, 4643, 4649, 4651, 4657, 4663, 4673, 4679, 4691,
66 | 4703, 4721, 4723, 4729, 4733, 4751, 4759, 4783, 4787, 4789, 4793, 4799,
67 | 4801, 4813, 4817, 4831, 4861, 4871, 4877, 4889,
68 | 4903, 4909, 4919, 4931, 4933, 4937, 4943, 4951, 4957, 4967, 4969, 4973, 4987, 4993, 4999,
69 | 5003, 5009, 5011, 5021, 5023, 5039, 5051, 5059, 5077, 5081, 5087, 5099,
70 | 5101, 5107, 5113, 5119, 5147, 5153, 5167, 5171, 5179, 5189, 5197,
71 | 5209, 5227, 5231, 5233, 5237, 5261, 5273, 5279, 5281, 5297,
72 | 5303, 5309, 5323, 5333, 5347, 5351, 5381, 5387, 5393, 5399,
73 | 5407, 5413, 5417, 5419, 5431, 5437, 5441, 5443, 5449, 5471, 5477, 5479, 5483,
74 | 5501, 5503, 5507, 5519, 5521, 5527, 5531, 5557, 5563, 5569, 5573, 5581, 5591,
75 | 5623, 5639, 5641, 5647, 5651, 5653, 5657, 5659, 5669, 5683, 5689, 5693,
76 | 5701, 5711, 5717, 5737, 5741, 5743, 5749, 5779, 5783, 5791,
77 | 5801, 5807, 5813, 5821, 5827, 5839, 5843, 5849, 5851, 5857, 5861, 5867, 5869, 5879, 5881, 5897,
78 | 5903, 5923, 5927, 5939, 5953, 5981, 5987,
79 | 6007, 6011, 6029, 6037, 6043, 6047, 6053, 6067, 6073, 6079, 6089, 6091,
80 | 6101, 6113, 6121, 6131, 6133, 6143, 6151, 6163, 6173, 6197, 6199,
81 | 6203, 6211, 6217, 6221, 6229, 6247, 6257, 6263, 6269, 6271, 6277, 6287, 6299,
82 | 6301, 6311, 6317, 6323, 6329, 6337, 6343, 6353, 6359, 6361, 6367, 6373, 6379, 6389, 6397,
83 | 6421, 6427, 6449, 6451, 6469, 6473, 6481, 6491,
84 | 6521, 6529, 6547, 6551, 6553, 6563, 6569, 6571, 6577, 6581, 6599,
85 | 6607, 6619, 6637, 6653, 6659, 6661, 6673, 6679, 6689, 6691,
86 | 6701, 6703, 6709, 6719, 6733, 6737, 6761, 6763, 6779, 6781, 6791, 6793,
87 | 6803, 6823, 6827, 6829, 6833, 6841, 6857, 6863, 6869, 6871, 6883, 6899,
88 | 6907, 6911, 6917, 6947, 6949, 6959, 6961, 6967, 6971, 6977, 6983, 6991, 6997,
89 | 7001, 7013, 7019, 7027, 7039, 7043, 7057, 7069, 7079,
90 | 7103, 7109, 7121, 7127, 7129, 7151, 7159, 7177, 7187, 7193,
91 | 7207, 7211, 7213, 7219, 7229, 7237, 7243, 7247, 7253, 7283, 7297,
92 | 7307, 7309, 7321, 7331, 7333, 7349, 7351, 7369, 7393,
93 | 7411, 7417, 7433, 7451, 7457, 7459, 7477, 7481, 7487, 7489, 7499,
94 | 7507, 7517, 7523, 7529, 7537, 7541, 7547, 7549, 7559, 7561, 7573, 7577, 7583, 7589, 7591,
95 | 7603, 7607, 7621, 7639, 7643, 7649, 7669, 7673, 7681, 7687, 7691, 7699,
96 | 7703, 7717, 7723, 7727, 7741, 7753, 7757, 7759, 7789, 7793,
97 | 7817, 7823, 7829, 7841, 7853, 7867, 7873, 7877, 7879, 7883,
98 | 7901, 7907, 7919
99 | ]
100 |
--------------------------------------------------------------------------------
/src/js/graphics.js:
--------------------------------------------------------------------------------
1 | /**
2 | * graphics.js
3 | * Functions for rendering of tuning graphics
4 | */
5 |
6 | // draws a graphic of the scale represented as notches on a horizontal rule
7 | function render_graphic_scale_rule() {
8 | let canvas = document.getElementById('graphic-scale-rule')
9 | let w = canvas.width
10 | let h = canvas.height
11 | let ctx = canvas.getContext('2d')
12 |
13 | // render background
14 | ctx.fillStyle = '#fff'
15 | ctx.fillRect(0, 0, w, h)
16 |
17 | // render a plain horizontal rule
18 | ctx.beginPath()
19 | ctx.moveTo(0, h * 0.5)
20 | ctx.lineTo(w, h * 0.5)
21 | ctx.strokeStyle = '#555'
22 | ctx.lineWidth = 3
23 | ctx.stroke()
24 |
25 | // if scale data exists then add some notches to the rule
26 | if (tuning_table.note_count > 0) {
27 | let equave = tuning_table.tuning_data[tuning_table.note_count - 1]
28 | for (i = 0; i < tuning_table.note_count; i++) {
29 | let pos = 1 + (w - 2) * (Math.log(tuning_table.tuning_data[i]) / Math.log(equave))
30 | ctx.beginPath()
31 | ctx.moveTo(pos, h * 0.4)
32 | ctx.lineTo(pos, h * 0.6)
33 | ctx.strokeStyle = '#555'
34 | ctx.lineWidth = 3
35 | ctx.stroke()
36 | }
37 | }
38 | }
39 |
40 | // init graphics
41 | render_graphic_scale_rule()
42 |
--------------------------------------------------------------------------------
/src/js/keymap.js:
--------------------------------------------------------------------------------
1 | /**
2 | * keymap.js
3 | * International keyboard layouts
4 | */
5 |
6 | // prettier-ignore
7 | var Layouts = {
8 | // English QWERTY Layout
9 | //
10 | // <\> is placed to the right of <'> because on ISO (EU) variants it's there.
11 | // The ANSI (US) variant places it to the right of <]>, but it's a less useful
12 | // position so it can be ignored.
13 | EN: [
14 | "1234567890-=",
15 | "QWERTYUIOP[]",
16 | "ASDFGHJKL;'\\",
17 | "ZXCVBNM,./",
18 | ],
19 |
20 | // Hungarian QWERTZ layout
21 | HU: [
22 | "123456789ñ/=",
23 | "QWERTZUIOP[]",
24 | "ASDFGHJKL;'\\",
25 | "YXCVBNM,.-",
26 | ],
27 |
28 | // Dvorak keyboard
29 | DK: [
30 | "1234567890-=",
31 | "',.PYFGCRL/@",
32 | "AOEUIDHTNS-\\",
33 | ";QJKXBMWVZ",
34 | ],
35 |
36 | // Programmer Dvorak keyboard
37 | PK: [
38 | "&7531902468#",
39 | ";,.PYFGCRL/@",
40 | "AOEUIDHTNS-\\",
41 | "'QJKXBMWVZ",
42 | ],
43 |
44 | // Colemak keyboard
45 | CO: [
46 | "1234567890-=",
47 | "QWFPGJLUY;[]",
48 | "ARSTDHNEIO'\\",
49 | "ZXCVBKM,./"
50 | ],
51 |
52 | // Colemak DH-m keyboard
53 | CO_DH: [
54 | "1234567890-=",
55 | "QWFPBJLUY;[]\\",
56 | "ARSTGMNEIO'",
57 | "ZXCDVKH,./"
58 | ]
59 | };
60 |
61 | // Map of irregular keycodes
62 | //
63 | // This website can be used to display the 'which' value for a given key:
64 | //
65 | // https://keycode.info
66 | //
67 | // prettier-ignore
68 | var Keycodes = {
69 | ";": 186,
70 | "=": 187,
71 | ",": 188,
72 | "-": 189,
73 | ".": 190,
74 | "/": 191,
75 | "ñ": 192,
76 | "[": 219,
77 | "\\": 220,
78 | "]": 221,
79 | "'": 222,
80 | "&": 166,
81 | "#": 163,
82 | }
83 |
84 | // Build Keymap from Layouts
85 | var Keymap = {}
86 | for (var id in Layouts) {
87 | Keymap[id] = buildKeymapFromLayout(Layouts[id])
88 | }
89 |
90 | function buildKeymapFromLayout(rows) {
91 | var map = {}
92 | for (var r = rows.length - 1; r >= 0; r--) {
93 | var row = rows[r]
94 | var rowId = rows.length - r - 2
95 | for (var c = 0; c < row.length; c++) {
96 | var keycode = Keycodes[row.charAt(c)] || row.charCodeAt(c)
97 | map[keycode] = [rowId, c]
98 | }
99 | }
100 | return map
101 | }
102 |
--------------------------------------------------------------------------------
/src/js/midi/commands.js:
--------------------------------------------------------------------------------
1 | const setPitchBendLimit = (channel, semitones) => {
2 | return [
3 | (commands.cc << 4) | (channel - 1),
4 | cc.registeredParameterLSB,
5 | 0,
6 | (commands.cc << 4) | (channel - 1),
7 | cc.registeredParameterMSB,
8 | 0,
9 | (commands.cc << 4) | (channel - 1),
10 | cc.dataEntry,
11 | semitones,
12 | (commands.cc << 4) | (channel - 1),
13 | cc.registeredParameterLSB,
14 | 127,
15 | (commands.cc << 4) | (channel - 1),
16 | cc.registeredParameterMSB,
17 | 127
18 | ]
19 | }
20 |
21 | const pitchBendAmountToDataBytes = (pitchBendAmount) => {
22 | const realValue = pitchBendAmount - pitchBendMin
23 | return [realValue & 0b01111111, (realValue >> 7) & 0b01111111]
24 | }
25 |
26 | const bendPitch = (channel, pitchBendAmount) => {
27 | return [
28 | ...[(commands.pitchbend << 4) | (channel - 1)],
29 | ...pitchBendAmountToDataBytes(pitchBendAmount)
30 | ]
31 | }
32 |
33 | const noteOn = (channel, note, pitchBendAmount = null, velocity = 127) => {
34 | return [
35 | ...(pitchBendAmount !== null ? bendPitch(channel, pitchBendAmount) : []),
36 | ...[(commands.noteOn << 4) | (channel - 1), note, velocity]
37 | ]
38 | }
39 |
40 | const noteOff = (channel, note, velocity = 127) => {
41 | return [(commands.noteOff << 4) | (channel - 1), note, velocity]
42 | }
43 |
--------------------------------------------------------------------------------
/src/js/midi/constants.js:
--------------------------------------------------------------------------------
1 | const whiteOnlyMap = {
2 | 0: 25,
3 | 2: 26,
4 | 4: 27,
5 | 5: 28,
6 | 7: 29,
7 | 9: 30,
8 | 11: 31,
9 | 12: 32,
10 | 14: 33,
11 | 16: 34,
12 | 17: 35,
13 | 19: 36,
14 | 21: 37,
15 | 23: 38,
16 | 24: 39,
17 | 26: 40,
18 | 28: 41,
19 | 29: 42,
20 | 31: 43,
21 | 33: 44,
22 | 35: 45,
23 | 36: 46,
24 | 38: 47,
25 | 40: 48,
26 | 41: 49,
27 | 43: 50,
28 | 45: 51,
29 | 47: 52,
30 | 48: 53,
31 | 50: 54,
32 | 52: 55,
33 | 53: 56,
34 | 55: 57,
35 | 57: 58,
36 | 59: 59,
37 | 60: 60,
38 | 62: 61,
39 | 64: 62,
40 | 65: 63,
41 | 67: 64,
42 | 69: 65,
43 | 71: 66,
44 | 72: 67,
45 | 74: 68,
46 | 76: 69,
47 | 77: 70,
48 | 79: 71,
49 | 81: 72,
50 | 83: 73,
51 | 84: 74,
52 | 86: 75,
53 | 88: 76,
54 | 89: 77,
55 | 91: 78,
56 | 93: 79,
57 | 95: 80,
58 | 96: 81,
59 | 98: 82,
60 | 100: 83,
61 | 101: 84,
62 | 103: 85,
63 | 105: 86,
64 | 107: 87,
65 | 108: 88,
66 | 110: 89,
67 | 112: 90,
68 | 113: 91,
69 | 115: 92,
70 | 117: 93,
71 | 119: 94,
72 | 120: 95,
73 | 122: 96,
74 | 124: 97,
75 | 125: 98,
76 | 127: 99
77 | }
78 |
79 | // https://www.midi.org/specifications/item/table-1-summary-of-midi-message
80 | const commands = {
81 | noteOn: 0b1001,
82 | noteOff: 0b1000,
83 | aftertouch: 0b1010,
84 | pitchbend: 0b1110,
85 | cc: 0b1011
86 | }
87 |
88 | // https://www.midi.org/specifications/item/table-3-control-change-messages-data-bytes-2
89 | // http://www.nortonmusic.com/midi_cc.html
90 | const cc = {
91 | dataEntry: 6,
92 | sustain: 64,
93 | registeredParameterLSB: 100,
94 | registeredParameterMSB: 101
95 | }
96 |
97 | /// 440Hz A4
98 | const referenceNote = {
99 | frequency: 440,
100 | id: 69
101 | }
102 |
103 | const pitchBendMin = 1 - (1 << 14) / 2 // -8191
104 | const pitchBendMax = (1 << 14) / 2 // 8192
105 |
106 | // settings for MIDI OUT ports
107 | const defaultInputData = {
108 | enabled: true,
109 | channels: [
110 | { id: 1, enabled: true, pitchBendAmount: 0 },
111 | { id: 2, enabled: true, pitchBendAmount: 0 },
112 | { id: 3, enabled: true, pitchBendAmount: 0 },
113 | { id: 4, enabled: true, pitchBendAmount: 0 },
114 | { id: 5, enabled: true, pitchBendAmount: 0 },
115 | { id: 6, enabled: true, pitchBendAmount: 0 },
116 | { id: 7, enabled: true, pitchBendAmount: 0 },
117 | { id: 8, enabled: true, pitchBendAmount: 0 },
118 | { id: 9, enabled: true, pitchBendAmount: 0 },
119 | { id: 10, enabled: false, pitchBendAmount: 0 }, // drum channel
120 | { id: 11, enabled: true, pitchBendAmount: 0 },
121 | { id: 12, enabled: true, pitchBendAmount: 0 },
122 | { id: 13, enabled: true, pitchBendAmount: 0 },
123 | { id: 14, enabled: true, pitchBendAmount: 0 },
124 | { id: 15, enabled: true, pitchBendAmount: 0 },
125 | { id: 16, enabled: true, pitchBendAmount: 0 }
126 | ]
127 | }
128 |
129 | // settings for MIDI IN ports
130 | const defaultOutputData = {
131 | enabled: false,
132 | channels: [
133 | { id: 1, enabled: true, pitchBendAmount: 0 },
134 | { id: 2, enabled: false, pitchBendAmount: 0 },
135 | { id: 3, enabled: false, pitchBendAmount: 0 },
136 | { id: 4, enabled: false, pitchBendAmount: 0 },
137 | { id: 5, enabled: false, pitchBendAmount: 0 },
138 | { id: 6, enabled: false, pitchBendAmount: 0 },
139 | { id: 7, enabled: false, pitchBendAmount: 0 },
140 | { id: 8, enabled: false, pitchBendAmount: 0 },
141 | { id: 9, enabled: false, pitchBendAmount: 0 },
142 | { id: 10, enabled: false, pitchBendAmount: 0 },
143 | { id: 11, enabled: false, pitchBendAmount: 0 },
144 | { id: 12, enabled: false, pitchBendAmount: 0 },
145 | { id: 13, enabled: false, pitchBendAmount: 0 },
146 | { id: 14, enabled: false, pitchBendAmount: 0 },
147 | { id: 15, enabled: false, pitchBendAmount: 0 },
148 | { id: 16, enabled: false, pitchBendAmount: 0 }
149 | ]
150 | }
151 |
152 | const octaveRatio = 2
153 | const semitonesPerOctave = 12
154 | const maxBendingDistanceInSemitones = 12
155 | const centsPerOctave = 1200
156 |
157 | const middleC = 60
158 | const drumChannel = 10 // when counting from 1
159 |
160 | const allMidiKeys = [...Array(128).keys()] // [0, 1, 2, ..., 127]
161 |
162 | const whiteMidiKeys = Object.keys(whiteOnlyMap).map((id) => parseInt(id))
163 |
164 | const blackMidiKeys = R.difference(allMidiKeys, whiteMidiKeys)
165 |
--------------------------------------------------------------------------------
/src/js/midi/math.js:
--------------------------------------------------------------------------------
1 | const moveNUnits = (ratioOfSymmetry, divisionsPerRatio, n, frequency) => {
2 | // return frequency * ratioOfSymmetry ** (n / divisionsPerRatio)
3 | return Decimal.mul(frequency, Decimal.pow(ratioOfSymmetry, Decimal.div(n, divisionsPerRatio)))
4 | }
5 |
6 | const getDistanceInUnits = (ratioOfSymmetry, divisionsPerRatio, freq2, freq1) => {
7 | // return divisionsPerRatio * Math.log(freq2 / freq1, ratioOfSymmetry)
8 | return Decimal.mul(divisionsPerRatio, Decimal.log(Decimal.div(freq2, freq1), ratioOfSymmetry))
9 | }
10 |
11 | const moveNSemitones = (n, frequency) => {
12 | return moveNUnits(octaveRatio, semitonesPerOctave, n, frequency)
13 | }
14 |
15 | const getDistanceInSemitones = (freq2, freq1) => {
16 | return getDistanceInUnits(octaveRatio, semitonesPerOctave, freq2, freq1)
17 | }
18 |
19 | const bendingRatio = moveNSemitones(maxBendingDistanceInSemitones, 1)
20 |
21 | const getBendingDistance = (freq2, freq1) => {
22 | return getDistanceInUnits(bendingRatio, pitchBendMax, freq2, freq1)
23 | }
24 |
25 | const getNoteFrequency = (midinote) => {
26 | return moveNSemitones(
27 | Decimal.sub(R.clamp(0, 127, midinote), referenceNote.id),
28 | referenceNote.frequency
29 | )
30 | }
31 |
32 | const getNoteId = (frequency) => {
33 | return Decimal.floor(
34 | Decimal.add(getDistanceInSemitones(frequency, referenceNote.frequency), referenceNote.id)
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/src/js/midi/midi.js:
--------------------------------------------------------------------------------
1 | /**
2 | * midi.js
3 | * Capture MIDI input for synth
4 | */
5 |
6 | const deviceChannelInfo = {}
7 |
8 | const getNameFromPort = (port) => {
9 | const { name, version, manufacturer } = port
10 | return `${name} (version ${version}) ${manufacturer}`
11 | }
12 |
13 | class MIDI extends EventEmitter {
14 | constructor() {
15 | super()
16 |
17 | this._ = {
18 | inited: false,
19 | supported: false,
20 | devices: {
21 | inputs: {},
22 | outputs: {}
23 | },
24 | whiteOnly: false
25 | }
26 | }
27 |
28 | set whiteOnly(value) {
29 | this._.whiteOnly = value
30 |
31 | allMidiKeys.forEach((note) => {
32 | for (let channel = 1; channel <= 16; channel++) {
33 | this.emit('note off', note, 1, channel)
34 | }
35 | })
36 | }
37 |
38 | async init() {
39 | if (!this._.inited) {
40 | this._.inited = true
41 |
42 | const enableMidiSupport = (midiAccess) => {
43 | this._.supported = true
44 |
45 | midiAccess.onstatechange = (event) => {
46 | initPort(event.port)
47 | }
48 |
49 | const inputs = midiAccess.inputs.values()
50 | for (let input = inputs.next(); input && !input.done; input = inputs.next()) {
51 | initPort(input.value)
52 | }
53 |
54 | const outputs = midiAccess.outputs.values()
55 | for (let output = outputs.next(); output && !output.done; output = outputs.next()) {
56 | initPort(output.value)
57 | }
58 | }
59 |
60 | const initPort = (port) => {
61 | const { devices } = this._
62 |
63 | if (port.type === 'input') {
64 | if (!devices.inputs[port.id]) {
65 | devices.inputs[port.id] = {
66 | port,
67 | name: getNameFromPort(port),
68 | ...R.clone(defaultInputData)
69 | }
70 | }
71 |
72 | devices.inputs[port.id].connected = false
73 | if (port.state === 'connected') {
74 | if (port.connection === 'closed') {
75 | port.open()
76 | } else if (port.connection === 'open') {
77 | port.onmidimessage = onMidiMessage(devices.inputs[port.id])
78 | devices.inputs[port.id].connected = true
79 | }
80 | }
81 | } else if (port.type === 'output') {
82 | if (!devices.outputs[port.id]) {
83 | devices.outputs[port.id] = {
84 | port,
85 | name: getNameFromPort(port),
86 | ...R.clone(defaultOutputData)
87 | }
88 | }
89 |
90 | if (port.state === 'connected') {
91 | if (port.connection === 'closed') {
92 | port.open()
93 | } else if (port.connection === 'open') {
94 | devices.outputs[port.id].connected = true
95 | }
96 | }
97 | }
98 |
99 | this.emit('update')
100 | }
101 |
102 | const onMidiMessage = (device) => (event) => {
103 | if (device.enabled) {
104 | const { whiteOnly } = this._
105 | const [data, ...params] = event.data
106 | const cmd = data >> 4
107 | const channel = data & 0x0f
108 |
109 | if (device.channels[channel]?.enabled === true) {
110 | switch (cmd) {
111 | case commands.noteOff:
112 | {
113 | const [note, velocity] = params
114 | if (whiteOnly) {
115 | if (whiteMidiKeys.includes(note)) {
116 | this.emit('note off', whiteOnlyMap[note], velocity, channel)
117 | }
118 | } else {
119 | this.emit('note off', note, velocity, channel)
120 | }
121 | }
122 | break
123 | case commands.noteOn:
124 | {
125 | const [note, velocity] = params
126 | if (whiteOnly) {
127 | if (whiteMidiKeys.includes(note)) {
128 | this.emit(
129 | 'note on',
130 | whiteOnlyMap[note],
131 | state.get('midi velocity sensing') ? velocity : 127,
132 | channel
133 | )
134 | }
135 | } else {
136 | this.emit(
137 | 'note on',
138 | note,
139 | state.get('midi velocity sensing') ? velocity : 127,
140 | channel
141 | )
142 | }
143 | }
144 | break
145 | case commands.aftertouch:
146 | {
147 | const [note, pressure] = params
148 | if (whiteOnly) {
149 | if (whiteMidiKeys.includes(note)) {
150 | this.emit('aftertouch', whiteOnlyMap[note], (pressure / 128) * 100, channel)
151 | }
152 | } else {
153 | this.emit('aftertouch', note, (pressure / 128) * 100, channel)
154 | }
155 | }
156 | break
157 | case commands.pitchbend:
158 | {
159 | const [low, high] = params
160 | this.emit('pitchbend', (((high << 7) | low) / 0x3fff - 1) * 100)
161 | }
162 | break
163 | case commands.cc:
164 | {
165 | const [cmd, value] = params
166 |
167 | switch (cmd) {
168 | case cc.sustain:
169 | this.emit('sustain', value >= 64)
170 | break
171 | }
172 | }
173 | break
174 | }
175 | }
176 | }
177 | }
178 |
179 | if (navigator.requestMIDIAccess) {
180 | const midiAccess = await navigator.requestMIDIAccess({ sysex: false })
181 | enableMidiSupport(midiAccess)
182 | this.emit('ready')
183 | } else {
184 | this.emit('blocked')
185 | }
186 | }
187 | }
188 |
189 | toggleDevice(type, deviceId, newValue = null) {
190 | const { devices } = this._
191 |
192 | const device = devices[`${type}s`][deviceId]
193 | device.enabled = newValue === null ? !device.enabled : newValue
194 |
195 | if (type === 'output') {
196 | if (device.enabled) {
197 | device.channels.forEach((channel) => {
198 | device.port.send(setPitchBendLimit(channel, maxBendingDistanceInSemitones))
199 | })
200 | } else {
201 | device.channels.forEach((channel) => {
202 | device.port.send(bendPitch(channel, 0))
203 | })
204 | }
205 | }
206 |
207 | this.emit('update')
208 | }
209 |
210 | setDevice(type, deviceId, newValue) {
211 | this.toggleDevice(type, deviceId, newValue)
212 | }
213 |
214 | toggleChannel(type, deviceId, channelId, newValue = null) {
215 | const { devices } = this._
216 |
217 | const device = devices[`${type}s`][deviceId]
218 | const channel = device.channels.find(({ id }) => id === channelId)
219 |
220 | newValue = newValue === null ? !channel.enabled : newValue
221 | if (channel.enabled !== newValue) {
222 | channel.enabled = newValue
223 | this.emit('update')
224 | }
225 | }
226 |
227 | setChannel(type, deviceId, channelId, newValue) {
228 | this.toggleChannel(type, deviceId, channelId, newValue)
229 | }
230 |
231 | getEnabledOutputs() {
232 | return Object.values(this._.devices.outputs).filter(({ enabled, channels }) => {
233 | return enabled === true && channels.find(({ enabled }) => enabled === true) !== undefined
234 | })
235 | }
236 |
237 | getLowestEnabledChannel(channels) {
238 | return channels.find(({ enabled }) => enabled === true)
239 | }
240 |
241 | playFrequency(frequency = 0) {
242 | const devices = this.getEnabledOutputs()
243 |
244 | if (devices.length) {
245 | devices.forEach(({ port, channels }) => {
246 | const channel = channels.find(({ enabled }) => enabled === true)
247 | if (!deviceChannelInfo[port.id]) {
248 | deviceChannelInfo[port.id] = {}
249 | }
250 | if (!deviceChannelInfo[port.id][channel]) {
251 | deviceChannelInfo[port.id][channel] = {
252 | pressedNoteIds: []
253 | }
254 | }
255 |
256 | if (frequency === 0) {
257 | if (deviceChannelInfo[port.id][channel].pressedNoteIds.length) {
258 | port.send(
259 | deviceChannelInfo[port.id][channel].pressedNoteIds.flatMap((noteId) => {
260 | return noteOff(channel, noteId)
261 | })
262 | )
263 |
264 | deviceChannelInfo[port.id][channel].pressedNoteIds = []
265 | }
266 | } else {
267 | const noteId = parseInt(getNoteId(frequency).toString())
268 | const pitchbendAmount = parseFloat(
269 | getBendingDistance(frequency, getNoteFrequency(noteId)).toString()
270 | )
271 |
272 | port.send(noteOn(channel, noteId, pitchbendAmount))
273 | deviceChannelInfo[port.id][channel].pressedNoteIds.push(noteId)
274 | }
275 | })
276 | }
277 | }
278 |
279 | stopFrequency() {
280 | this.playFrequency(0)
281 | }
282 |
283 | isSupported() {
284 | return this._.supported
285 | }
286 | }
287 |
288 | // -------------------------------------------
289 |
290 | const midi = new MIDI()
291 |
292 | jQuery(() => {
293 | const midiEnablerBtn = jQuery('#midi-enabler')
294 |
295 | midi
296 | .on('blocked', () => {
297 | midiEnablerBtn
298 | .prop('disabled', false)
299 | .removeClass('btn-success')
300 | .addClass('btn-danger')
301 | .text('off (blocked)')
302 | })
303 | .on('note on', (note, velocity, channel) => {
304 | synth.noteOn(note, velocity)
305 | })
306 | .on('note off', (note, velocity, channel) => {
307 | synth.noteOff(note)
308 | })
309 | .on('update', () => {
310 | if (state.get('midi modal visible')) {
311 | state.set('midi modal visible', true, true)
312 | }
313 | })
314 |
315 | midiEnablerBtn.on('click', async () => {
316 | await midi.init()
317 |
318 | if (midi.isSupported()) {
319 | state.set('midi enabled', true)
320 | }
321 | })
322 | })
323 |
--------------------------------------------------------------------------------
/src/js/midi/ui.js:
--------------------------------------------------------------------------------
1 | const MidiChannel = ({ type, deviceId, channelId, enabled }) => {
2 | const template = document.createElement('template')
3 | template.innerHTML = `
4 |
5 |
8 |
9 |
10 | `
11 | const content = template.content
12 | content.querySelector('input[type="checkbox"]').addEventListener('change', (e) => {
13 | const isEnabled = e.target.checked
14 | midi.setChannel(type, deviceId, channelId, isEnabled)
15 | })
16 | return content
17 | }
18 |
19 | const MidiDevice = ({ type, deviceId, name, channels, enabled }) => {
20 | const template = document.createElement('template')
21 | template.innerHTML = `
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | `
30 | const content = template.content
31 | channels.forEach(({ id, enabled }) => {
32 | content
33 | .querySelector('.channels')
34 | .appendChild(MidiChannel({ type, deviceId, channelId: id, enabled }))
35 | })
36 | content.getElementById(`${type}--${name}`).addEventListener('change', (e) => {
37 | const isEnabled = e.target.checked
38 | midi.setDevice(type, deviceId, isEnabled)
39 | })
40 | return content
41 | }
42 |
43 | const renderMidiInputsTo = (container) => {
44 | const { inputs } = midi._.devices
45 |
46 | container.innerHTML = ''
47 | Object.values(inputs).forEach((input) => {
48 | container.appendChild(MidiDevice({ type: 'input', deviceId: input.port.id, ...input }))
49 | })
50 | }
51 |
52 | const renderMidiOutputsTo = (container) => {
53 | const { outputs } = midi._.devices
54 |
55 | container.innerHTML = ''
56 | Object.values(outputs).forEach((output) => {
57 | container.appendChild(MidiDevice({ type: 'output', deviceId: output.port.id, ...output }))
58 | })
59 | }
60 |
61 | const renderMidiSettingsTo = (container) => {
62 | const { whiteOnly } = midi._
63 | const whiteModeSwitch = container.querySelector('#input_midi_whitemode')
64 | whiteModeSwitch.checked = whiteOnly
65 | whiteModeSwitch.addEventListener('change', (e) => {
66 | midi.whiteOnly = e.target.checked
67 | })
68 | }
69 |
--------------------------------------------------------------------------------
/src/js/state/actions-dom.js:
--------------------------------------------------------------------------------
1 | // DOM changes, need to sync with state
2 |
3 | jQuery('#input_range_main_vol').on('input', function () {
4 | state.set('main volume', parseFloat(jQuery(this).val()))
5 | })
6 |
7 | jQuery('#velocity-toggler').on('click', () => {
8 | state.set('midi velocity sensing', !state.get('midi velocity sensing'))
9 | })
10 |
11 | // hide virtual keyboard when mobile hamburger menu button is clicked
12 | jQuery('button.navbar-toggle').on('click', () => {
13 | state.set('virtual keyboard visible', false)
14 | state.set('mobile menu visible', !state.get('mobile menu visible'))
15 | })
16 |
17 | // Touch keyboard (#nav_play) option clicked
18 | jQuery('#nav_play, #launch-kbd').on('click', (event) => {
19 | event.preventDefault()
20 | state.set('virtual keyboard visible', !state.get('virtual keyboard visible'))
21 | })
22 |
--------------------------------------------------------------------------------
/src/js/state/actions.js:
--------------------------------------------------------------------------------
1 | // non-DOM changes, need to sync with state
2 |
3 | document.addEventListener('keydown', (event) => {
4 | if (event.key === 'Escape') {
5 | state.set('virtual keyboard visible', false)
6 | }
7 | })
8 |
--------------------------------------------------------------------------------
/src/js/state/on-ready.js:
--------------------------------------------------------------------------------
1 | // when all event hooks set up the initial change events can fire
2 |
3 | state.ready()
4 |
--------------------------------------------------------------------------------
/src/js/state/reactions-dom.js:
--------------------------------------------------------------------------------
1 | // data changed, sync it with the DOM
2 |
3 | state.on('main volume', (value) => {
4 | jQuery('#input_range_main_vol').val(value)
5 | })
6 |
7 | state.on('midi velocity sensing', (value) => {
8 | const velocityToggleBtn = jQuery('#velocity-toggler')
9 |
10 | if (value) {
11 | velocityToggleBtn.removeClass('btn-basic').addClass('btn-success').text('velocity: on')
12 | } else {
13 | velocityToggleBtn.removeClass('btn-success').addClass('btn-basic').text('velocity: off')
14 | }
15 | })
16 |
17 | state.on('virtual keyboard visible', (value) => {
18 | if (value) {
19 | touch_kbd_open()
20 | } else {
21 | touch_kbd_close()
22 | }
23 | })
24 |
25 | state.on('mobile menu visible', (value) => {
26 | if (value) {
27 | jQuery('#mobile-menu').show()
28 | } else {
29 | jQuery('#mobile-menu').hide()
30 | }
31 | })
32 |
33 | state.on('midi modal visible', (value, ...args) => {
34 | const midiModal = document.getElementById('modal_midi_settings')
35 | if (value) {
36 | renderMidiInputsTo(midiModal.querySelector('.inputs'))
37 | renderMidiOutputsTo(midiModal.querySelector('.outputs'))
38 | renderMidiSettingsTo(midiModal.querySelector('.settings'))
39 | }
40 | })
41 |
42 | state.on('midi enabled', (value) => {
43 | if (value) {
44 | jQuery('#midi-enabler')
45 | .prop('disabled', true)
46 | .removeClass('btn-danger')
47 | .addClass('btn-success')
48 | .text('on')
49 | }
50 | })
51 |
--------------------------------------------------------------------------------
/src/js/state/reactions.js:
--------------------------------------------------------------------------------
1 | // data changed, handle programmatic reaction - no DOM changes
2 |
3 | state.on('main volume', (newValue) => {
4 | synth.setMainVolume(newValue)
5 | })
6 |
--------------------------------------------------------------------------------
/src/js/state/state.js:
--------------------------------------------------------------------------------
1 | class State extends EventEmitter {
2 | constructor(initialData = {}) {
3 | super()
4 | this.data = initialData
5 | }
6 | get(key) {
7 | return this.data[key]
8 | }
9 | set(key, newValue, forceEmit = false) {
10 | const oldValue = this.data[key]
11 | if (oldValue !== newValue) {
12 | this.data[key] = newValue
13 | this.emit(key, newValue, oldValue)
14 | } else {
15 | if (forceEmit) {
16 | this.emit(key, newValue, oldValue)
17 | }
18 | }
19 | }
20 | ready() {
21 | Object.entries(this.data).forEach(([key, value]) => {
22 | this.emit(key, value)
23 | })
24 | }
25 | }
26 |
27 | const state = new State({
28 | 'main volume': 0.8,
29 | 'midi enabled': false,
30 | 'midi velocity sensing': true,
31 | 'virtual keyboard visible': false,
32 | 'mobile menu visible': false,
33 | 'midi modal visible': false
34 | })
35 |
--------------------------------------------------------------------------------
/src/js/synth.js:
--------------------------------------------------------------------------------
1 | /**
2 | * synth.js
3 | * Web audio synth
4 | */
5 |
6 | const synth = new Synth()
7 |
8 | // keycode_to_midinote()
9 | // it turns a keycode to a MIDI note based on this reference layout:
10 | //
11 | // 1 2 3 4 5 6 7 8 9 0 - =
12 | // Q W E R T Y U I O P [ ]
13 | // A S D F G H J K L ; ' \
14 | // Z X C V B N M , . /
15 | //
16 | function keycode_to_midinote(keycode) {
17 | // get row/col vals from the keymap
18 | var key = synth.keymap[keycode]
19 |
20 | // return false if there is no note assigned to this key
21 | if (R.isNil(key)) {
22 | return false
23 | }
24 |
25 | var [row, col] = key
26 | return (
27 | row * synth.isomorphicMapping.vertical +
28 | col * synth.isomorphicMapping.horizontal +
29 | tuning_table['base_midi_note']
30 | )
31 | }
32 |
33 | function touch_to_midinote([row, col]) {
34 | if (R.isNil(row) || R.isNil(col)) {
35 | return false
36 | }
37 |
38 | return (
39 | row * synth.isomorphicMapping.vertical +
40 | col * synth.isomorphicMapping.horizontal +
41 | tuning_table['base_midi_note']
42 | )
43 | }
44 |
45 | // is_qwerty_active()
46 | // check if qwerty key playing should be active
47 | // returns true if focus is in safe area for typing
48 | // returns false if focus is on an input or textarea element
49 | function is_qwerty_active() {
50 | jQuery('div#qwerty-indicator').empty()
51 | var focus = document.activeElement.tagName
52 | if (focus == 'TEXTAREA' || focus == 'INPUT') {
53 | jQuery('div#qwerty-indicator').html(
54 | '![]()
Keyboard disabled
Click here to enable QWERTY keyboard playing.
'
55 | )
56 | return false
57 | } else {
58 | jQuery('div#qwerty-indicator').html(
59 | '![]()
Keyboard enabled
Press QWERTY keys to play current tuning.
'
60 | )
61 | return true
62 | }
63 | }
64 |
65 | // KEYDOWN -- capture keyboard input
66 | document.addEventListener('keydown', function (event) {
67 | // bail if focus is on an input or textarea element
68 | if (!is_qwerty_active()) {
69 | return false
70 | }
71 |
72 | // bail, if a modifier is pressed alongside the key
73 | if (!isSimpleKeypress(event)) {
74 | return false
75 | }
76 |
77 | const midiNote = keycode_to_midinote(event.which) // midi note number 0-127
78 | const velocity = 100
79 |
80 | if (midiNote !== false) {
81 | event.preventDefault()
82 | synth.noteOn(midiNote, velocity)
83 | }
84 | })
85 |
86 | // KEYUP -- capture keyboard input
87 | document.addEventListener('keyup', function (event) {
88 | // bail, if a modifier is pressed alongside the key
89 | if (!isSimpleKeypress(event)) {
90 | return false
91 | }
92 | const midiNote = keycode_to_midinote(event.which)
93 | if (midiNote !== false) {
94 | event.preventDefault()
95 | synth.noteOff(midiNote)
96 | }
97 | })
98 |
99 | // -[virtual keyboard mobile]-----------------------------------------------
100 |
101 | // TODO: multi-touch support; https://stackoverflow.com/a/7236327/1806628
102 |
103 | jQuery('#virtual-keyboard')
104 | .on('touchstart', (e) => {
105 | e.preventDefault()
106 | synth.noteOn(touch_to_midinote(getCoordsFromKey(e.target)))
107 | e.target.classList.add('active')
108 | })
109 | .on('touchend', (e) => {
110 | e.preventDefault()
111 | synth.noteOff(touch_to_midinote(getCoordsFromKey(e.target)))
112 | e.target.classList.remove('active')
113 | })
114 | .on('touchcancel', (e) => {
115 | e.preventDefault()
116 | synth.noteOff(touch_to_midinote(getCoordsFromKey(e.target)))
117 | e.target.classList.remove('active')
118 | })
119 | // .on('touchmove', (e) => {
120 | // e.preventDefault()
121 | // console.log('touchmove', e.target)
122 | // })
123 |
124 | // -[virtual keyboard desktop]----------------------------------------------
125 |
126 | const LEFT_MOUSE_BTN = 0
127 |
128 | let isMousePressed = false
129 |
130 | jQuery('#virtual-keyboard')
131 | .on('mousedown', 'td', (e) => {
132 | if (e.button !== LEFT_MOUSE_BTN) {
133 | return
134 | }
135 |
136 | isMousePressed = true
137 | synth.noteOn(touch_to_midinote(getCoordsFromKey(e.target)))
138 | e.target.classList.add('active')
139 | })
140 | .on('mouseup', 'td', (e) => {
141 | if (e.button !== LEFT_MOUSE_BTN) {
142 | return
143 | }
144 |
145 | isMousePressed = false
146 | synth.noteOff(touch_to_midinote(getCoordsFromKey(e.target)))
147 | e.target.classList.remove('active')
148 | })
149 | .on('mouseenter', 'td', (e) => {
150 | if (!isMousePressed) {
151 | return
152 | }
153 |
154 | synth.noteOn(touch_to_midinote(getCoordsFromKey(e.target)))
155 | e.target.classList.add('active')
156 | })
157 | .on('mouseleave', 'td', (e) => {
158 | if (!isMousePressed) {
159 | return
160 | }
161 |
162 | synth.noteOff(touch_to_midinote(getCoordsFromKey(e.target)))
163 | e.target.classList.remove('active')
164 | })
165 |
--------------------------------------------------------------------------------
/src/js/synth/Delay.js:
--------------------------------------------------------------------------------
1 | class Delay {
2 | constructor(synth) {
3 | this.time = 0.3
4 | this.gain = 0.4
5 | this.inited = false
6 | this.synth = synth
7 | }
8 | enable() {
9 | if (this.inited) {
10 | this.panL.connect(this.synth.masterGain)
11 | this.panR.connect(this.synth.masterGain)
12 | }
13 | }
14 | disable() {
15 | if (this.inited) {
16 | this.panL.disconnect(this.synth.masterGain)
17 | this.panR.disconnect(this.synth.masterGain)
18 | }
19 | }
20 | init(audioCtx) {
21 | if (!this.inited) {
22 | this.inited = true
23 | this.channelL = audioCtx.createDelay(5.0)
24 | this.channelR = audioCtx.createDelay(5.0)
25 | this.gainL = audioCtx.createGain(0.8)
26 | this.gainR = audioCtx.createGain(0.8)
27 | // this.lowpassL = audioCtx.createBiquadFilter()
28 | // this.lowpassR = audioCtx.createBiquadFilter()
29 | // this.highpassL = audioCtx.createBiquadFilter()
30 | // this.highpassR = audioCtx.createBiquadFilter()
31 | this.panL = audioCtx.createPanner()
32 | this.panR = audioCtx.createPanner()
33 |
34 | // feedback loop with gain stage
35 | this.channelL.connect(this.gainL)
36 | this.gainL.connect(this.channelR)
37 | this.channelR.connect(this.gainR)
38 | this.gainR.connect(this.channelL)
39 |
40 | // filters
41 | // this.gainL.connect( this.lowpassL );
42 | // this.gainR.connect( this.lowpassR );
43 | // this.lowpassL.frequency.value = 6500;
44 | // this.lowpassR.frequency.value = 7000;
45 | // this.lowpassL.Q.value = 0.7;
46 | // this.lowpassR.Q.value = 0.7;
47 | // this.lowpassL.type = 'lowpass';
48 | // this.lowpassR.type = 'lowpass';
49 | // this.lowpassL.connect( this.highpassL );
50 | // this.lowpassR.connect( this.highpassR );
51 | // this.highpassL.frequency.value = 130;
52 | // this.highpassR.frequency.value = 140;
53 | // this.highpassL.Q.value = 0.7;
54 | // this.highpassR.Q.value = 0.7;
55 | // this.highpassL.type = 'highpass';
56 | // this.highpassR.type = 'highpass';
57 | // this.highpassL.connect( this.panL );
58 | // this.highpassR.connect( this.panR );
59 |
60 | // panning
61 | this.gainL.connect(this.panL) // if you uncomment the above filters lines, then comment out this line
62 | this.gainR.connect(this.panR) // if you uncomment the above filters lines, then comment out this line
63 | this.panL.setPosition(-1, 0, 0)
64 | this.panR.setPosition(1, 0, 0)
65 |
66 | // setup delay time and gain for delay lines
67 | const now = synth.now()
68 | this.channelL.delayTime.setValueAtTime(this.time, now)
69 | this.channelR.delayTime.setValueAtTime(this.time, now)
70 | this.gainL.gain.setValueAtTime(this.gain, now)
71 | this.gainR.gain.setValueAtTime(this.gain, now)
72 |
73 | // check on init if user has already enabled delay
74 | if (jQuery('#input_checkbox_delay_on').is(':checked')) {
75 | this.enable()
76 | }
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/js/synth/Synth.js:
--------------------------------------------------------------------------------
1 | class Synth {
2 | constructor() {
3 | this.keymap = Keymap.EN
4 | this.isomorphicMapping = {
5 | vertical: 5, // how many scale degrees as you move up/down by rows
6 | horizontal: 1 // how many scale degrees as you move left/right by cols
7 | }
8 | this.voices = []
9 | this.midinotes_to_voices = {} // polyphonic voice allocation
10 | this.voices_to_midinotes = {} // polyphonic voice allocation
11 | this.polyphony = !R.isNil(localStorage.getItem('max_polyphony'))
12 | ? localStorage.getItem('max_polyphony')
13 | : 16
14 | this.nextVoice = 0
15 | this.waveform = 'semisine'
16 | this.mainVolume = 0.8
17 | this.inited = false
18 |
19 | this.delay = new Delay(this)
20 | }
21 |
22 | init() {
23 | // only init once
24 | if (!this.inited) {
25 | this.inited = true
26 |
27 | // set up Web Audio API context
28 | this.audioCtx = new (window.AudioContext || window.webkitAudioContext)()
29 |
30 | // set up custom waveforms
31 | this.custom_waveforms = {
32 | warm1: this.audioCtx.createPeriodicWave(
33 | new Float32Array([0, 10, 2, 2, 2, 1, 1, 0.5]),
34 | new Float32Array([0, 0, 0, 0, 0, 0, 0, 0])
35 | ),
36 | warm2: this.audioCtx.createPeriodicWave(
37 | new Float32Array([0, 10, 5, 3.33, 2, 1]),
38 | new Float32Array([0, 0, 0, 0, 0, 0])
39 | ),
40 | warm3: this.audioCtx.createPeriodicWave(
41 | new Float32Array([0, 10, 5, 5, 3]),
42 | new Float32Array([0, 0, 0, 0, 0])
43 | ),
44 | warm4: this.audioCtx.createPeriodicWave(
45 | new Float32Array([0, 10, 2, 2, 1]),
46 | new Float32Array([0, 0, 0, 0, 0])
47 | ),
48 | octaver: this.audioCtx.createPeriodicWave(
49 | new Float32Array([0, 1000, 500, 0, 333, 0, 0, 0, 250, 0, 0, 0, 0, 0, 0, 0, 166]),
50 | new Float32Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
51 | ),
52 | brightness: this.audioCtx.createPeriodicWave(
53 | new Float32Array([
54 | 0, 10, 0, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 1, 1, 1, 1, 0.75, 0.5, 0.2, 0.1
55 | ]),
56 | new Float32Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
57 | ),
58 | harmonicbell: this.audioCtx.createPeriodicWave(
59 | new Float32Array([0, 10, 2, 2, 2, 2, 0, 0, 0, 0, 0, 7]),
60 | new Float32Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
61 | ),
62 | template: this.audioCtx.createPeriodicWave(
63 | // first element is DC offset, second element is fundamental, third element is 2nd harmonic, etc.
64 | new Float32Array([0, 1, 0.5, 0.333, 0.25, 0.2, 0.167]), // sine components
65 | new Float32Array([0, 0, 0.0, 0.0, 0.0, 0.0, 0.0]) // cosine components
66 | )
67 | }
68 |
69 | // DC-blocked semisine
70 | const semisineSineComponents = new Float32Array(64);
71 | const semisineCosineComponents = new Float32Array(64);
72 | for (let n = 1; n < 64; ++n) {
73 | semisineCosineComponents[n] = 1 / (1 - 4*n*n);
74 | }
75 | this.custom_waveforms.semisine = this.audioCtx.createPeriodicWave(
76 | semisineSineComponents,
77 | semisineCosineComponents
78 | );
79 |
80 | // set up master gain
81 | this.masterGain = this.audioCtx.createGain()
82 | this.masterGain.gain.value = this.mainVolume
83 |
84 | // set up master filter
85 | this.masterLPfilter = this.audioCtx.createBiquadFilter()
86 | this.masterLPfilter.frequency.value = 5000
87 | this.masterLPfilter.Q.value = 1
88 | this.masterLPfilter.type = 'lowpass'
89 |
90 | // connect master gain control > filter > master output
91 | this.masterGain.connect(this.masterLPfilter)
92 | this.masterLPfilter.connect(this.audioCtx.destination)
93 |
94 | // init delay
95 | this.delay.init(this.audioCtx)
96 |
97 | // init free running oscillators
98 | for (i = 0; i < this.polyphony; i++) {
99 | this.voices[i] = new Voice(this.audioCtx)
100 | this.voices[i].bindDelay(this.delay)
101 | this.voices[i].bindSynth(this)
102 | this.voices[i].init()
103 | }
104 | }
105 | }
106 |
107 | setMainVolume(newValue) {
108 | const oldValue = this.mainVolume
109 | if (newValue !== oldValue) {
110 | this.mainVolume = newValue
111 | if (this.inited) {
112 | const now = this.now()
113 | this.masterGain.gain.value = newValue
114 | this.masterGain.gain.setValueAtTime(newValue, now)
115 | }
116 | }
117 | }
118 |
119 | noteOn(midinote, velocity = 127) {
120 | const frequency = tuning_table.freq[midinote]
121 |
122 | if (!R.isNil(frequency)) {
123 | midi.playFrequency(frequency)
124 |
125 | // make sure note triggers only on first input (prevent duplicate notes)
126 | if (R.isNil(this.midinotes_to_voices[midinote])) {
127 | this.init()
128 |
129 | // round robin voice allocation, but skip voices that are still being held
130 | for (i = this.nextVoice; i < this.nextVoice + this.polyphony; i++) {
131 | // if next voice is free, use it
132 | if (R.isNil(this.voices_to_midinotes[(i + 1) % this.polyphony])) {
133 | this.nextVoice = (i + 1) % this.polyphony
134 | break
135 | }
136 | // if no free voices are found when the loop ends, voice stealing will result
137 | }
138 |
139 | // keep track of allocated voices
140 | this.midinotes_to_voices[midinote] = this.nextVoice
141 | this.voices_to_midinotes[this.nextVoice] = midinote
142 |
143 | // trigger note start
144 | this.voices[this.midinotes_to_voices[midinote]].start(frequency, velocity)
145 |
146 | // indicate playing note
147 | jQuery('#tuning-table-row-' + midinote).addClass('bg-playnote')
148 | console.log(this.midinotes_to_voices)
149 | //console.log("Play note " + midinote + " (" + frequency.toFixed(3) + " Hz) velocity " + velocity);
150 | }
151 | }
152 | }
153 | noteOff(midinote) {
154 | midi.stopFrequency()
155 |
156 | if (!R.isNil(this.midinotes_to_voices[midinote])) {
157 | // release the note
158 | this.voices[this.midinotes_to_voices[midinote]].stop()
159 |
160 | // voice allocation
161 | delete this.voices_to_midinotes[this.midinotes_to_voices[midinote]]
162 | delete this.midinotes_to_voices[midinote]
163 |
164 | // indicate stopped note
165 | jQuery('#tuning-table-row-' + midinote).removeClass('bg-playnote')
166 | if (Object.values(this.midinotes_to_voices).length) {
167 | console.log(this.midinotes_to_voices)
168 | }
169 | }
170 | }
171 |
172 | now() {
173 | return this.audioCtx.currentTime
174 | }
175 |
176 | // this function stops all active voices and cuts the delay
177 | panic() {
178 | // show which voices are active (playing)
179 | console.log(this.voices)
180 |
181 | // loop through active voices
182 | for (let i = 0; i < this.polyphony; i++) {
183 | // turn off voice
184 | this.noteOff(this.voices_to_midinotes[i])
185 | }
186 |
187 | // turn down delay gain
188 | jQuery('#input_range_feedback_gain').val(0)
189 | this.delay.gain = 0
190 | const now = this.now()
191 | this.delay.gainL.gain.setValueAtTime(this.delay.gain, now)
192 | this.delay.gainR.gain.setValueAtTime(this.delay.gain, now)
193 |
194 | midi.stopFrequency()
195 | }
196 | }
197 |
--------------------------------------------------------------------------------
/src/js/synth/Voice.js:
--------------------------------------------------------------------------------
1 | const getEnvelopeByName = (name) => {
2 | const envelope = {
3 | attackTime: 0,
4 | decayTime: 0,
5 | sustain: 1,
6 | releaseTime: 0
7 | }
8 |
9 | switch (name) {
10 | case 'organ':
11 | envelope.attackTime = 0.01
12 | envelope.decayTime = 0.3
13 | envelope.sustain = 0.8
14 | envelope.releaseTime = 0.01
15 | break
16 | case 'pad':
17 | envelope.attackTime = 1
18 | envelope.decayTime = 3
19 | envelope.sustain = 0.5
20 | envelope.releaseTime = 0.7
21 | break
22 | case 'perc-short':
23 | envelope.attackTime = 0.005
24 | envelope.decayTime = 0.3
25 | envelope.sustain = 0.00001
26 | envelope.releaseTime = 0.05
27 | break
28 | case 'perc-medium':
29 | envelope.attackTime = 0.005
30 | envelope.decayTime = 1.5
31 | envelope.sustain = 0.00001
32 | envelope.releaseTime = 0.25
33 | break
34 | case 'perc-long':
35 | envelope.attackTime = 0.01
36 | envelope.decayTime = 8
37 | envelope.sustain = 0.00001
38 | envelope.releaseTime = 0.8
39 | break
40 | }
41 |
42 | return envelope
43 | }
44 |
45 | const getEnvelopeName = () => jQuery('#input_select_synth_amp_env').val()
46 |
47 | // https://github.com/mohayonao/pseudo-audio-param/blob/master/lib/expr.js#L3
48 | function getLinearRampToValueAtTime(t, v0, v1, t0, t1) {
49 | var a
50 |
51 | if (t <= t0) {
52 | return v0
53 | }
54 | if (t1 <= t) {
55 | return v1
56 | }
57 |
58 | a = (t - t0) / (t1 - t0)
59 |
60 | return v0 + a * (v1 - v0)
61 | }
62 |
63 | // https://github.com/mohayonao/pseudo-audio-param/blob/master/lib/expr.js#L18
64 | function getExponentialRampToValueAtTime(t, v0, v1, t0, t1) {
65 | var a
66 |
67 | if (t <= t0) {
68 | return v0
69 | }
70 | if (t1 <= t) {
71 | return v1
72 | }
73 | if (v0 === v1) {
74 | return v0
75 | }
76 |
77 | a = (t - t0) / (t1 - t0)
78 |
79 | if ((0 < v0 && 0 < v1) || (v0 < 0 && v1 < 0)) {
80 | return v0 * Math.pow(v1 / v0, a)
81 | }
82 |
83 | return 0
84 | }
85 |
86 | const interpolateValueAtTime = (minValue, maxValue, envelope, t) => {
87 | // interpolate attack
88 | if (envelope.attackTime > t) {
89 | return getLinearRampToValueAtTime(t, minValue, maxValue, 0, envelope.attackTime)
90 | }
91 |
92 | // interpolate decay
93 | if (envelope.attackTime + envelope.decayTime > t) {
94 | return getExponentialRampToValueAtTime(
95 | t,
96 | maxValue,
97 | maxValue * envelope.sustain,
98 | envelope.attackTime,
99 | envelope.attackTime + envelope.decayTime
100 | )
101 | }
102 |
103 | // interpolate sustain
104 | return maxValue * envelope.sustain
105 | }
106 |
107 | class Voice {
108 | constructor(audioCtx) {
109 | // set up oscillator
110 | this.vco = audioCtx.createOscillator()
111 |
112 | // set up amplitude envelope generator
113 | this.vca = audioCtx.createGain()
114 | }
115 |
116 | init() {
117 | // timing
118 | const now = this.synth.now()
119 |
120 | this.vca.gain.setValueAtTime(0, now)
121 |
122 | // routing
123 | this.vco.connect(this.vca)
124 | this.vco.start()
125 | this.vca.connect(this.delay.channelL)
126 | this.vca.connect(this.synth.masterGain)
127 | }
128 |
129 | start(frequency, velocity) {
130 | // start timing
131 | const now = this.synth.now()
132 | this.vca.gain._startTime = now
133 |
134 | // tune oscillator to correct frequency
135 | this.vco.frequency.setValueAtTime(frequency, now)
136 |
137 | // set oscillator waveform
138 | switch (this.synth.waveform) {
139 | case 'warm1':
140 | this.vco.setPeriodicWave(synth.custom_waveforms.warm1)
141 | break
142 | case 'warm2':
143 | this.vco.setPeriodicWave(synth.custom_waveforms.warm2)
144 | break
145 | case 'warm3':
146 | this.vco.setPeriodicWave(synth.custom_waveforms.warm3)
147 | break
148 | case 'warm4':
149 | this.vco.setPeriodicWave(synth.custom_waveforms.warm4)
150 | break
151 | case 'octaver':
152 | this.vco.setPeriodicWave(synth.custom_waveforms.octaver)
153 | break
154 | case 'brightness':
155 | this.vco.setPeriodicWave(synth.custom_waveforms.brightness)
156 | break
157 | case 'harmonicbell':
158 | this.vco.setPeriodicWave(synth.custom_waveforms.harmonicbell)
159 | break
160 | case 'semisine':
161 | this.vco.setPeriodicWave(synth.custom_waveforms.semisine)
162 | break
163 | default:
164 | this.vco.type = this.synth.waveform
165 | }
166 |
167 | // get target gain
168 | if (velocity === 0) {
169 | // in exponentialRampToValueAtTime, target gain can't be 0
170 | this.targetGain = 0.00001
171 | } else {
172 | // use velocity to determine target gain
173 | this.targetGain = velocity = (0.2 * velocity) / 127
174 | }
175 |
176 | // get and set amplitude envelope
177 | const envelope = getEnvelopeByName(getEnvelopeName())
178 | this.attackTime = envelope.attackTime
179 | this.decayTime = envelope.decayTime
180 | this.sustain = envelope.sustain
181 | this.releaseTime = envelope.releaseTime
182 |
183 | // Attack
184 | this.cancelEnvelope(this.vca.gain, now)
185 | this.vca.gain.setValueAtTime(0, now)
186 | this.vca.gain.linearRampToValueAtTime(this.targetGain, now + this.attackTime)
187 |
188 | // Decay & Sustain
189 | this.vca.gain.exponentialRampToValueAtTime(
190 | this.targetGain * this.sustain,
191 | now + this.attackTime + this.decayTime
192 | )
193 | }
194 |
195 | stop() {
196 | // timing
197 | const now = this.synth.now()
198 |
199 | // Release
200 | this.cancelEnvelope(this.vca.gain, now)
201 | this.vca.gain.setTargetAtTime(0.0, now, this.releaseTime)
202 | }
203 |
204 | // cancels any scheduled envelope changes in a given property's value
205 | cancelEnvelope(property, now) {
206 | // Firefox and Safari do not support cancelAndHoldAtTime
207 | if (isFunction(property.cancelAndHoldAtTime)) {
208 | property.cancelAndHoldAtTime(now)
209 | } else {
210 | property.cancelScheduledValues(now)
211 | property.setValueAtTime(
212 | interpolateValueAtTime(
213 | 0.00001,
214 | this.targetGain,
215 | getEnvelopeByName(getEnvelopeName()),
216 | now - property._startTime
217 | ),
218 | now
219 | )
220 | }
221 | }
222 |
223 | bindSynth(synth) {
224 | this.synth = synth
225 | }
226 | bindDelay(delay) {
227 | this.delay = delay
228 | }
229 | }
230 |
--------------------------------------------------------------------------------
/src/js/ui.js:
--------------------------------------------------------------------------------
1 | /**
2 | * ui.js
3 | * User interface
4 | */
5 |
6 | // use jQuery UI tooltips instead of default browser tooltips
7 | jQuery(function () {
8 | jQuery(document).tooltip()
9 | })
10 |
11 | // set "accordion" settings UI
12 | jQuery(function () {
13 | jQuery('#settings-accordion').accordion({
14 | collapsible: true, // allow all tabs to be closed
15 | active: false, // start all tabs closed
16 | heightStyle: 'content', // size each section to content
17 | icons: null, // turn off triangle icons
18 | header: '> div > h3'
19 | })
20 | })
21 |
22 | function touch_kbd_open() {
23 | // check if scale already set up - we can't use the touch kbd if there is no scale
24 | if (tuning_table['note_count'] == 0) {
25 | alert("Can't open the touch keyboard until you have created or loaded a scale.")
26 | return
27 | }
28 |
29 | // remove info from keys
30 | jQuery('#virtual-keyboard td').each(function (index) {
31 | // clear content of cell
32 | jQuery(this).empty()
33 | // reset any classes that might be on the cell
34 | jQuery(this).attr('class', 'key')
35 | })
36 |
37 | // display tuning info on virtual keys
38 | jQuery('#virtual-keyboard td').each(function () {
39 | // get the coord data attribute and figure out the midinote
40 | const midinote = touch_to_midinote(getCoordsFromKey(this))
41 |
42 | // add text to key
43 | //jQuery(this).append("midi " + midinote + "
");
44 | //jQuery(this).append("" + tuning_table['freq'][midinote].toFixed(1) + "
Hz
");
45 |
46 | // get the number representing this key color, with the first item being 0
47 | var keynum = (midinote - tuning_table['base_midi_note']).mod(key_colors.length)
48 |
49 | // set the color of the key
50 | this.style.backgroundColor = key_colors[keynum]
51 | })
52 |
53 | state.set('mobile menu visible', false)
54 |
55 | // show the virtual keyboard
56 | jQuery('#virtual-keyboard').slideDown()
57 | }
58 |
59 | function touch_kbd_close() {
60 | // hide the virtual keyboard
61 | jQuery('#virtual-keyboard').slideUp()
62 | }
63 |
--------------------------------------------------------------------------------
/src/js/user.js:
--------------------------------------------------------------------------------
1 | /**
2 | * user.js
3 | * Add your own hacks and hotfixes below
4 | */
5 |
6 | // declare custom variables and functions here
7 |
8 | // any code added within the below function will be called last when the page loads
9 | function run_user_scripts_on_document_ready() {}
10 |
--------------------------------------------------------------------------------
/src/lib/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/lib/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.eot
--------------------------------------------------------------------------------
/src/lib/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/lib/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.ttf
--------------------------------------------------------------------------------
/src/lib/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/lib/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.woff
--------------------------------------------------------------------------------
/src/lib/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SeanArchibald/scale-workshop/f84432ca94e3832c39999d108df4aa16afedf0d2/src/lib/bootstrap-3.3.7-dist/fonts/glyphicons-halflings-regular.woff2
--------------------------------------------------------------------------------
/src/lib/bootstrap-3.3.7-dist/js/npm.js:
--------------------------------------------------------------------------------
1 | // This file is autogenerated via the `commonjs` Grunt task. You can require() this file in a CommonJS environment.
2 | require('../../js/transition.js')
3 | require('../../js/alert.js')
4 | require('../../js/button.js')
5 | require('../../js/carousel.js')
6 | require('../../js/collapse.js')
7 | require('../../js/dropdown.js')
8 | require('../../js/modal.js')
9 | require('../../js/tooltip.js')
10 | require('../../js/popover.js')
11 | require('../../js/scrollspy.js')
12 | require('../../js/tab.js')
13 | require('../../js/affix.js')
--------------------------------------------------------------------------------
/src/lib/eventemitter3.js:
--------------------------------------------------------------------------------
1 | // source: https://github.com/primus/eventemitter3/blob/master/index.js
2 |
3 | 'use strict';
4 |
5 | var has = Object.prototype.hasOwnProperty
6 | , prefix = '~';
7 |
8 | /**
9 | * Constructor to create a storage for our `EE` objects.
10 | * An `Events` instance is a plain object whose properties are event names.
11 | *
12 | * @constructor
13 | * @private
14 | */
15 | function Events() {}
16 |
17 | //
18 | // We try to not inherit from `Object.prototype`. In some engines creating an
19 | // instance in this way is faster than calling `Object.create(null)` directly.
20 | // If `Object.create(null)` is not supported we prefix the event names with a
21 | // character to make sure that the built-in object properties are not
22 | // overridden or used as an attack vector.
23 | //
24 | if (Object.create) {
25 | Events.prototype = Object.create(null);
26 |
27 | //
28 | // This hack is needed because the `__proto__` property is still inherited in
29 | // some old browsers like Android 4, iPhone 5.1, Opera 11 and Safari 5.
30 | //
31 | if (!new Events().__proto__) prefix = false;
32 | }
33 |
34 | /**
35 | * Representation of a single event listener.
36 | *
37 | * @param {Function} fn The listener function.
38 | * @param {*} context The context to invoke the listener with.
39 | * @param {Boolean} [once=false] Specify if the listener is a one-time listener.
40 | * @constructor
41 | * @private
42 | */
43 | function EE(fn, context, once) {
44 | this.fn = fn;
45 | this.context = context;
46 | this.once = once || false;
47 | }
48 |
49 | /**
50 | * Add a listener for a given event.
51 | *
52 | * @param {EventEmitter} emitter Reference to the `EventEmitter` instance.
53 | * @param {(String|Symbol)} event The event name.
54 | * @param {Function} fn The listener function.
55 | * @param {*} context The context to invoke the listener with.
56 | * @param {Boolean} once Specify if the listener is a one-time listener.
57 | * @returns {EventEmitter}
58 | * @private
59 | */
60 | function addListener(emitter, event, fn, context, once) {
61 | if (typeof fn !== 'function') {
62 | throw new TypeError('The listener must be a function');
63 | }
64 |
65 | var listener = new EE(fn, context || emitter, once)
66 | , evt = prefix ? prefix + event : event;
67 |
68 | if (!emitter._events[evt]) emitter._events[evt] = listener, emitter._eventsCount++;
69 | else if (!emitter._events[evt].fn) emitter._events[evt].push(listener);
70 | else emitter._events[evt] = [emitter._events[evt], listener];
71 |
72 | return emitter;
73 | }
74 |
75 | /**
76 | * Clear event by name.
77 | *
78 | * @param {EventEmitter} emitter Reference to the `EventEmitter` instance.
79 | * @param {(String|Symbol)} evt The Event name.
80 | * @private
81 | */
82 | function clearEvent(emitter, evt) {
83 | if (--emitter._eventsCount === 0) emitter._events = new Events();
84 | else delete emitter._events[evt];
85 | }
86 |
87 | /**
88 | * Minimal `EventEmitter` interface that is molded against the Node.js
89 | * `EventEmitter` interface.
90 | *
91 | * @constructor
92 | * @public
93 | */
94 | function EventEmitter() {
95 | this._events = new Events();
96 | this._eventsCount = 0;
97 | }
98 |
99 | /**
100 | * Return an array listing the events for which the emitter has registered
101 | * listeners.
102 | *
103 | * @returns {Array}
104 | * @public
105 | */
106 | EventEmitter.prototype.eventNames = function eventNames() {
107 | var names = []
108 | , events
109 | , name;
110 |
111 | if (this._eventsCount === 0) return names;
112 |
113 | for (name in (events = this._events)) {
114 | if (has.call(events, name)) names.push(prefix ? name.slice(1) : name);
115 | }
116 |
117 | if (Object.getOwnPropertySymbols) {
118 | return names.concat(Object.getOwnPropertySymbols(events));
119 | }
120 |
121 | return names;
122 | };
123 |
124 | /**
125 | * Return the listeners registered for a given event.
126 | *
127 | * @param {(String|Symbol)} event The event name.
128 | * @returns {Array} The registered listeners.
129 | * @public
130 | */
131 | EventEmitter.prototype.listeners = function listeners(event) {
132 | var evt = prefix ? prefix + event : event
133 | , handlers = this._events[evt];
134 |
135 | if (!handlers) return [];
136 | if (handlers.fn) return [handlers.fn];
137 |
138 | for (var i = 0, l = handlers.length, ee = new Array(l); i < l; i++) {
139 | ee[i] = handlers[i].fn;
140 | }
141 |
142 | return ee;
143 | };
144 |
145 | /**
146 | * Return the number of listeners listening to a given event.
147 | *
148 | * @param {(String|Symbol)} event The event name.
149 | * @returns {Number} The number of listeners.
150 | * @public
151 | */
152 | EventEmitter.prototype.listenerCount = function listenerCount(event) {
153 | var evt = prefix ? prefix + event : event
154 | , listeners = this._events[evt];
155 |
156 | if (!listeners) return 0;
157 | if (listeners.fn) return 1;
158 | return listeners.length;
159 | };
160 |
161 | /**
162 | * Calls each of the listeners registered for a given event.
163 | *
164 | * @param {(String|Symbol)} event The event name.
165 | * @returns {Boolean} `true` if the event had listeners, else `false`.
166 | * @public
167 | */
168 | EventEmitter.prototype.emit = function emit(event, a1, a2, a3, a4, a5) {
169 | var evt = prefix ? prefix + event : event;
170 |
171 | if (!this._events[evt]) return false;
172 |
173 | var listeners = this._events[evt]
174 | , len = arguments.length
175 | , args
176 | , i;
177 |
178 | if (listeners.fn) {
179 | if (listeners.once) this.removeListener(event, listeners.fn, undefined, true);
180 |
181 | switch (len) {
182 | case 1: return listeners.fn.call(listeners.context), true;
183 | case 2: return listeners.fn.call(listeners.context, a1), true;
184 | case 3: return listeners.fn.call(listeners.context, a1, a2), true;
185 | case 4: return listeners.fn.call(listeners.context, a1, a2, a3), true;
186 | case 5: return listeners.fn.call(listeners.context, a1, a2, a3, a4), true;
187 | case 6: return listeners.fn.call(listeners.context, a1, a2, a3, a4, a5), true;
188 | }
189 |
190 | for (i = 1, args = new Array(len -1); i < len; i++) {
191 | args[i - 1] = arguments[i];
192 | }
193 |
194 | listeners.fn.apply(listeners.context, args);
195 | } else {
196 | var length = listeners.length
197 | , j;
198 |
199 | for (i = 0; i < length; i++) {
200 | if (listeners[i].once) this.removeListener(event, listeners[i].fn, undefined, true);
201 |
202 | switch (len) {
203 | case 1: listeners[i].fn.call(listeners[i].context); break;
204 | case 2: listeners[i].fn.call(listeners[i].context, a1); break;
205 | case 3: listeners[i].fn.call(listeners[i].context, a1, a2); break;
206 | case 4: listeners[i].fn.call(listeners[i].context, a1, a2, a3); break;
207 | default:
208 | if (!args) for (j = 1, args = new Array(len -1); j < len; j++) {
209 | args[j - 1] = arguments[j];
210 | }
211 |
212 | listeners[i].fn.apply(listeners[i].context, args);
213 | }
214 | }
215 | }
216 |
217 | return true;
218 | };
219 |
220 | /**
221 | * Add a listener for a given event.
222 | *
223 | * @param {(String|Symbol)} event The event name.
224 | * @param {Function} fn The listener function.
225 | * @param {*} [context=this] The context to invoke the listener with.
226 | * @returns {EventEmitter} `this`.
227 | * @public
228 | */
229 | EventEmitter.prototype.on = function on(event, fn, context) {
230 | return addListener(this, event, fn, context, false);
231 | };
232 |
233 | /**
234 | * Add a one-time listener for a given event.
235 | *
236 | * @param {(String|Symbol)} event The event name.
237 | * @param {Function} fn The listener function.
238 | * @param {*} [context=this] The context to invoke the listener with.
239 | * @returns {EventEmitter} `this`.
240 | * @public
241 | */
242 | EventEmitter.prototype.once = function once(event, fn, context) {
243 | return addListener(this, event, fn, context, true);
244 | };
245 |
246 | /**
247 | * Remove the listeners of a given event.
248 | *
249 | * @param {(String|Symbol)} event The event name.
250 | * @param {Function} fn Only remove the listeners that match this function.
251 | * @param {*} context Only remove the listeners that have this context.
252 | * @param {Boolean} once Only remove one-time listeners.
253 | * @returns {EventEmitter} `this`.
254 | * @public
255 | */
256 | EventEmitter.prototype.removeListener = function removeListener(event, fn, context, once) {
257 | var evt = prefix ? prefix + event : event;
258 |
259 | if (!this._events[evt]) return this;
260 | if (!fn) {
261 | clearEvent(this, evt);
262 | return this;
263 | }
264 |
265 | var listeners = this._events[evt];
266 |
267 | if (listeners.fn) {
268 | if (
269 | listeners.fn === fn &&
270 | (!once || listeners.once) &&
271 | (!context || listeners.context === context)
272 | ) {
273 | clearEvent(this, evt);
274 | }
275 | } else {
276 | for (var i = 0, events = [], length = listeners.length; i < length; i++) {
277 | if (
278 | listeners[i].fn !== fn ||
279 | (once && !listeners[i].once) ||
280 | (context && listeners[i].context !== context)
281 | ) {
282 | events.push(listeners[i]);
283 | }
284 | }
285 |
286 | //
287 | // Reset the array, or remove it completely if we have no more listeners.
288 | //
289 | if (events.length) this._events[evt] = events.length === 1 ? events[0] : events;
290 | else clearEvent(this, evt);
291 | }
292 |
293 | return this;
294 | };
295 |
296 | /**
297 | * Remove all listeners, or those of the specified event.
298 | *
299 | * @param {(String|Symbol)} [event] The event name.
300 | * @returns {EventEmitter} `this`.
301 | * @public
302 | */
303 | EventEmitter.prototype.removeAllListeners = function removeAllListeners(event) {
304 | var evt;
305 |
306 | if (event) {
307 | evt = prefix ? prefix + event : event;
308 | if (this._events[evt]) clearEvent(this, evt);
309 | } else {
310 | this._events = new Events();
311 | this._eventsCount = 0;
312 | }
313 |
314 | return this;
315 | };
316 |
317 | //
318 | // Alias methods names because people roll like that.
319 | //
320 | EventEmitter.prototype.off = EventEmitter.prototype.removeListener;
321 | EventEmitter.prototype.addListener = EventEmitter.prototype.on;
322 |
323 | //
324 | // Expose the prefix.
325 | //
326 | EventEmitter.prefixed = prefix;
327 |
328 | //
329 | // Allow `EventEmitter` to be imported as module namespace.
330 | //
331 | EventEmitter.EventEmitter = EventEmitter;
332 |
333 | //
334 | // Expose the module.
335 | //
336 | if ('undefined' !== typeof module) {
337 | module.exports = EventEmitter;
338 | }
339 |
--------------------------------------------------------------------------------
/src/lib/jquery-ui-1.12.1/AUTHORS.txt:
--------------------------------------------------------------------------------
1 | Authors ordered by first contribution
2 | A list of current team members is available at http://jqueryui.com/about
3 |
4 | Paul Bakaus
5 | Richard Worth
6 | Yehuda Katz
7 | Sean Catchpole
8 | John Resig
9 | Tane Piper
10 | Dmitri Gaskin
11 | Klaus Hartl
12 | Stefan Petre
13 | Gilles van den Hoven
14 | Micheil Bryan Smith
15 | Jörn Zaefferer
16 | Marc Grabanski
17 | Keith Wood
18 | Brandon Aaron
19 | Scott González
20 | Eduardo Lundgren
21 | Aaron Eisenberger
22 | Joan Piedra
23 | Bruno Basto
24 | Remy Sharp
25 | Bohdan Ganicky
26 | David Bolter
27 | Chi Cheng
28 | Ca-Phun Ung
29 | Ariel Flesler
30 | Maggie Wachs
31 | Scott Jehl
32 | Todd Parker
33 | Andrew Powell
34 | Brant Burnett
35 | Douglas Neiner
36 | Paul Irish
37 | Ralph Whitbeck
38 | Thibault Duplessis
39 | Dominique Vincent
40 | Jack Hsu
41 | Adam Sontag
42 | Carl Fürstenberg
43 | Kevin Dalman
44 | Alberto Fernández Capel
45 | Jacek Jędrzejewski (http://jacek.jedrzejewski.name)
46 | Ting Kuei
47 | Samuel Cormier-Iijima
48 | Jon Palmer
49 | Ben Hollis
50 | Justin MacCarthy
51 | Eyal Kobrigo
52 | Tiago Freire
53 | Diego Tres
54 | Holger Rüprich
55 | Ziling Zhao
56 | Mike Alsup
57 | Robson Braga Araujo
58 | Pierre-Henri Ausseil
59 | Christopher McCulloh
60 | Andrew Newcomb
61 | Lim Chee Aun
62 | Jorge Barreiro
63 | Daniel Steigerwald
64 | John Firebaugh
65 | John Enters
66 | Andrey Kapitcyn
67 | Dmitry Petrov
68 | Eric Hynds
69 | Chairat Sunthornwiphat
70 | Josh Varner
71 | Stéphane Raimbault
72 | Jay Merrifield
73 | J. Ryan Stinnett
74 | Peter Heiberg
75 | Alex Dovenmuehle
76 | Jamie Gegerson
77 | Raymond Schwartz
78 | Phillip Barnes
79 | Kyle Wilkinson
80 | Khaled AlHourani
81 | Marian Rudzynski
82 | Jean-Francois Remy
83 | Doug Blood
84 | Filippo Cavallarin
85 | Heiko Henning
86 | Aliaksandr Rahalevich
87 | Mario Visic
88 | Xavi Ramirez
89 | Max Schnur
90 | Saji Nediyanchath
91 | Corey Frang
92 | Aaron Peterson
93 | Ivan Peters
94 | Mohamed Cherif Bouchelaghem
95 | Marcos Sousa
96 | Michael DellaNoce
97 | George Marshall
98 | Tobias Brunner
99 | Martin Solli
100 | David Petersen
101 | Dan Heberden
102 | William Kevin Manire
103 | Gilmore Davidson
104 | Michael Wu
105 | Adam Parod
106 | Guillaume Gautreau
107 | Marcel Toele
108 | Dan Streetman
109 | Matt Hoskins
110 | Giovanni Giacobbi
111 | Kyle Florence
112 | Pavol Hluchý
113 | Hans Hillen
114 | Mark Johnson
115 | Trey Hunner
116 | Shane Whittet
117 | Edward A Faulkner
118 | Adam Baratz
119 | Kato Kazuyoshi
120 | Eike Send
121 | Kris Borchers
122 | Eddie Monge
123 | Israel Tsadok
124 | Carson McDonald
125 | Jason Davies
126 | Garrison Locke
127 | David Murdoch
128 | Benjamin Scott Boyle
129 | Jesse Baird
130 | Jonathan Vingiano
131 | Dylan Just
132 | Hiroshi Tomita
133 | Glenn Goodrich
134 | Tarafder Ashek-E-Elahi
135 | Ryan Neufeld
136 | Marc Neuwirth
137 | Philip Graham
138 | Benjamin Sterling
139 | Wesley Walser
140 | Kouhei Sutou
141 | Karl Kirch
142 | Chris Kelly
143 | Jason Oster
144 | Felix Nagel
145 | Alexander Polomoshnov
146 | David Leal
147 | Igor Milla
148 | Dave Methvin
149 | Florian Gutmann
150 | Marwan Al Jubeh
151 | Milan Broum
152 | Sebastian Sauer
153 | Gaëtan Muller
154 | Michel Weimerskirch
155 | William Griffiths
156 | Stojce Slavkovski
157 | David Soms
158 | David De Sloovere
159 | Michael P. Jung
160 | Shannon Pekary
161 | Dan Wellman
162 | Matthew Edward Hutton
163 | James Khoury
164 | Rob Loach
165 | Alberto Monteiro
166 | Alex Rhea
167 | Krzysztof Rosiński
168 | Ryan Olton
169 | Genie <386@mail.com>
170 | Rick Waldron
171 | Ian Simpson
172 | Lev Kitsis
173 | TJ VanToll
174 | Justin Domnitz
175 | Douglas Cerna
176 | Bert ter Heide
177 | Jasvir Nagra
178 | Yuriy Khabarov <13real008@gmail.com>
179 | Harri Kilpiö
180 | Lado Lomidze
181 | Amir E. Aharoni
182 | Simon Sattes
183 | Jo Liss
184 | Guntupalli Karunakar
185 | Shahyar Ghobadpour
186 | Lukasz Lipinski
187 | Timo Tijhof
188 | Jason Moon
189 | Martin Frost
190 | Eneko Illarramendi
191 | EungJun Yi
192 | Courtland Allen
193 | Viktar Varvanovich
194 | Danny Trunk
195 | Pavel Stetina
196 | Michael Stay
197 | Steven Roussey
198 | Michael Hollis
199 | Lee Rowlands
200 | Timmy Willison
201 | Karl Swedberg
202 | Baoju Yuan
203 | Maciej Mroziński
204 | Luis Dalmolin
205 | Mark Aaron Shirley
206 | Martin Hoch
207 | Jiayi Yang
208 | Philipp Benjamin Köppchen
209 | Sindre Sorhus
210 | Bernhard Sirlinger
211 | Jared A. Scheel
212 | Rafael Xavier de Souza
213 | John Chen
214 | Robert Beuligmann
215 | Dale Kocian
216 | Mike Sherov
217 | Andrew Couch
218 | Marc-Andre Lafortune
219 | Nate Eagle
220 | David Souther
221 | Mathias Stenbom
222 | Sergey Kartashov
223 | Avinash R
224 | Ethan Romba
225 | Cory Gackenheimer
226 | Juan Pablo Kaniefsky
227 | Roman Salnikov
228 | Anika Henke
229 | Samuel Bovée
230 | Fabrício Matté
231 | Viktor Kojouharov
232 | Pawel Maruszczyk (http://hrabstwo.net)
233 | Pavel Selitskas
234 | Bjørn Johansen
235 | Matthieu Penant
236 | Dominic Barnes
237 | David Sullivan
238 | Thomas Jaggi
239 | Vahid Sohrabloo
240 | Travis Carden
241 | Bruno M. Custódio
242 | Nathanael Silverman
243 | Christian Wenz
244 | Steve Urmston
245 | Zaven Muradyan
246 | Woody Gilk
247 | Zbigniew Motyka
248 | Suhail Alkowaileet
249 | Toshi MARUYAMA
250 | David Hansen
251 | Brian Grinstead
252 | Christian Klammer
253 | Steven Luscher
254 | Gan Eng Chin
255 | Gabriel Schulhof
256 | Alexander Schmitz
257 | Vilhjálmur Skúlason
258 | Siebrand Mazeland
259 | Mohsen Ekhtiari
260 | Pere Orga
261 | Jasper de Groot