├── .editorconfig ├── .gitattributes ├── .gitignore ├── License.md ├── README.md ├── babel.config.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── public ├── favicon.ico └── index.html ├── src ├── App.vue ├── Synth │ ├── Effects.js │ ├── ModMatrix.js │ ├── PresetManager.js │ ├── Voice.js │ ├── commands │ │ └── index.js │ ├── control │ │ └── index.js │ ├── globals.js │ ├── index.js │ ├── nodes │ │ ├── Distortion.js │ │ ├── ENVELOPE.js │ │ ├── FILTER.js │ │ ├── FX.js │ │ ├── LFO.js │ │ ├── VCO.js │ │ ├── index.js │ │ └── mod.js │ ├── presets.js │ └── synthfunctions.js ├── assets │ ├── fonts │ │ ├── OpenSansPX.ttf │ │ └── OpenSansPXBold.ttf │ ├── images │ │ └── wood.jpg │ └── scss │ │ ├── _fonts.scss │ │ ├── _variables.scss │ │ ├── global.scss │ │ └── main.scss ├── components │ ├── Piano.vue │ ├── Synthesizer.vue │ ├── controllers │ │ ├── BaseController.vue │ │ ├── Controller.vue │ │ ├── DisplayPicker.vue │ │ ├── Fader.vue │ │ ├── Knob.vue │ │ ├── Toggle.vue │ │ ├── VSelect.vue │ │ ├── WavePicker.vue │ │ └── __tests__ │ │ │ ├── BaseController.spec.js │ │ │ ├── Controller.spec.js │ │ │ └── DisplayPicker.spec.js │ ├── display │ │ ├── DisplayBase.vue │ │ └── LedDisplay.vue │ ├── global │ │ └── .keep │ ├── matrix │ │ ├── Matrix.vue │ │ ├── MatrixRow.vue │ │ └── MatrixRowAmount.vue │ ├── ui │ │ └── VSection.vue │ └── vis │ │ ├── Analyser.vue │ │ ├── MainScreen.vue │ │ └── Meter.vue ├── main.js ├── mixins │ ├── BaseControlMixin.js │ ├── Control.js │ ├── Defer.js │ ├── ExpControlMixin.js │ ├── LogControl.js │ └── StepControlMixin.js └── utils │ ├── __tests__ │ └── utils.spec.js │ └── index.js └── vue.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | max_line_length = 100 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | src/App.vue merge=ours 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | 25 | coverage 26 | TODO.md 27 | media 28 | -------------------------------------------------------------------------------- /License.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Razz21 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-synth 2 | 3 | Subtractive polyphonic synthesizer build with Tone.js and Vue.js. 4 | --- 5 | 6 | Recommended browsers: Firefox | Chrome 7 | 8 | # Table of content 9 | - [1. Project setup](#1-project-setup) 10 | - [2. Overview](#2-overview) 11 | - [2.1. Introduction](#21-introduction) 12 | - [2.1.1. Oscillators](#211-oscillators) 13 | - [2.1.2. Filter](#212-filter) 14 | - [2.1.3. Modulation](#213-modulation) 15 | - [2.1.4. Master Effects](#214-master-effects) 16 | - [2.2. Audio Path](#22-audio-path) 17 | - [2.3. Controlling Knobs](#23-controlling-knobs) 18 | - [2.4. Keyboard](#24-keyboard) 19 | - [2.5. Controlling Presets](#25-controlling-presets) 20 | - [3. Components](#3-components) 21 | - [3.1. Oscillator](#31-oscillator) 22 | - [3.2. Mixer](#32-mixer) 23 | - [3.3. Envelope Generators](#33-envelope-generators) 24 | - [3.4. LFOs](#34-lfos) 25 | - [3.5. Filter](#35-filter) 26 | - [3.6. Master](#36-master) 27 | - [3.7. Modulation Matrix](#37-modulation-matrix) 28 | - [3.8. Oscilloscope](#38-oscilloscope) 29 | - [3.9. Master Effects](#39-master-effects) 30 | - [3.9.1. Distortion](#391-distortion) 31 | - [3.9.2. Chorus](#392-chorus) 32 | - [3.9.3. Delay](#393-delay) 33 | - [3.9.4. Reverb](#394-reverb) 34 | - [3.9.5. Limiter](#395-limiter) 35 | - [4. Tips on Reducing CPU Usage](#4-tips-on-reducing-cpu-usage) 36 | - [5. Technical Notes](#5-technical-notes) 37 | - [5.1. Oscillator](#51-oscillator) 38 | - [5.2. LFO](#52-lfo) 39 | - [5.3. Envelope](#53-envelope) 40 | - [5.4. Master Effects](#54-master-effects) 41 | - [5.5. Modulation](#55-modulation) 42 | - [5.6. Voice Management](#56-voice-management) 43 | - [6. References and inspiration](#6-references-and-inspiration) 44 | 45 | 46 | # 1. Project setup 47 | 48 | Install dependencies: 49 | ```js 50 | npm install 51 | ``` 52 | 53 | Run command to compile and hot-reload for development: 54 | ```js 55 | npm run serve 56 | ``` 57 | 58 | # 2. Overview 59 | 60 | ## 2.1. Introduction 61 | 62 | Synth is 8-voices virtual polyphonic synthesizer designed for creating music in web browser. 63 | 64 | ![synth][synth] 65 | 66 | ### 2.1.1. Oscillators 67 | 68 | Synth houses 2 unison oscillators, which generate 4 basic waveforms: 69 | - sine 70 | - sawtooth 71 | - triangle 72 | - square 73 | 74 | Each oscillator is capable of producing 4 unison voices, adding up to a total 8 voices per note. With its 8 notes of polyphony this means you can play up to 64 voices simultaneously. 75 | Synth is equipped with additional one White Noise Generator. 76 | 77 | ### 2.1.2. Filter 78 | 79 | For sound shaping purposes Synth offers one filter with 3 basic types (lowpass, bandpass, lowpass), 2 rolloff slopes (12 and 24 dB/oct) and bypass mode. 80 | 81 | ### 2.1.3. Modulation 82 | 83 | To sculpture the sound Synth proivde 2 polyphonic LFO's which can be used to modulate a whole set of different parameters. Next to it, it is possible to use the amplitude envelope and filter envelope as source of modulation. 84 | 85 | ### 2.1.4. Master Effects 86 | 87 | Master effects section offers five sound effects: 88 | - Distortion - eight types of distortion; 89 | - Chorus - stereo chorus effect; 90 | - Delay - feedback delay effect; 91 | - Reverb - effect with adjustable size and pre-delay; 92 | - Limiter - brickwall limiter with adjustable threshold parameter for overload protection. 93 | 94 | ## 2.2. Audio Path 95 | 96 | Synth is build on classic *voice* architecture, that dynamically allocate limited amount of voices during playtime. The scheme below shows the internal structure of the audio path of this synthesizer. 97 | 98 | ![Structure of synthesizer][diagram] 99 | 100 | Advantage of this design is reduced demand for resources (polyphonic synth does not need to provide separate voice for every note for the sort of playing them in parallel - vide [paraphonic synthesizer][1]), but, on the other hand, this approach forms a little additional overhead in creating logic for dynamic voices management. 101 | 102 | ## 2.3. Controlling Knobs 103 | 104 | Rotary knobs, faders and other controls can be controlled by clicking the handle and **dragging** up or down in vertical direction. For higher accuracy hold ***Shift*** key while dragging the mouse. Current knob value will be displayed for short amount of time on *preset display*. 105 | 106 | ![Controlling Knobs][knobControl] 107 | If you want to know the exact value of a parameter, but you do not wish to change it, you can simply **click** a knob once. This will display its value on the screen without changing it. 108 | **Double clicking** on control resets its value to default. 109 | To speed up workflow rotate knobs and sliders allow you to change its value using **mouse wheel**. 110 | 111 | ## 2.4. Keyboard 112 | Playing piano notes just with mouse is not very comfortable (or even possible for chords). However it is possible to use your desktop keyboard to trigger keys signals. The keyboard section at the bottom of the user interface consist of a 4-octave keyboard that will display stroked keys if note do not exceed its range. Switching current keyboard octave down and up is possible with keys **Z** and **X** respectively. 113 | 114 | ## 2.5. Controlling Presets 115 | 116 | Synth is shipped with simple preset manager with option to preview pre-defined programs. 117 | ![Preset display][preset] 118 | 119 | On both sides of display there are placed two buttons to change current preset to previous/next program respectively. 120 | 121 | 122 | # 3. Components 123 | 124 | In this chapter each synthesizer components will be discussed one by one. 125 | 126 | ## 3.1. Oscillator 127 | 128 | ![Oscillator][oscillator] 129 | 130 | ***Wave*** 131 | Values: sine | sawtooth | triangle | square 132 | By dragging the waveform selector you can select of type to generate. 133 | 134 | ***Octave*** 135 | Range: ± 3600 cents (±3 octaves) 136 | Tune oscillator voices up/down in octave range. 137 | 138 | ***Seminote*** 139 | Range: ± 1200 cents (±12 seminotes) 140 | Tune oscillator voices up/down in seminotes range. 141 | 142 | ***Finetune*** 143 | Range: ± 100 cents (±1 seminote) 144 | Fine-tune oscillator pitch voices between two half notes. 145 | 146 | ***Phase*** 147 | Range: [0, 360] 148 | Change start phase of waveform. This parameter does not have impact on wave, if *RETRIG* button is disabled. 149 | 150 | ***Retrigger*** 151 | Values: on | off 152 | Force all voices to start at the exact same location every time a new note is triggered. That location can be changed using the *Phase* knob. 153 | 154 | ***Voices*** 155 | Range: [0, 4] 156 | Select number of generated unison voices. You can turn off oscillator by setting the number of voices to 0. 157 | 158 | ## 3.2. Mixer 159 | 160 | ![mixer][mixer] 161 | 162 | The mixer section allows to control the level and panorama of oscillators 1-2 and Noise Generator. 163 | 164 | ***Volume*** 165 | Range: [0, 1] 166 | Set the output volume of the oscillator. 167 | 168 | ***Pan*** 169 | Range: [-1, 1] 170 | Change panorama of oscillator output to the left(-1) or right(1) channel. 171 | 172 | ## 3.3. Envelope Generators 173 | Synth offers two ADSR (Attack, Decay, Sustain, Release) envelopes: 174 | - Filter Env - controls filter frequency; 175 | - AMP Env - controls the progression of the volume of a sound; 176 | 177 | ![eg][eg] 178 | 179 | Both works in the same manner and can be used as modulation souce in Modulation Matrix. 180 | 181 | ***Attack*** 182 | Range: [1ms, 10s] 183 | Specifies the duration it takes for the amplitude envelope to go from zero toits maximum level. 184 | 185 | ***Decay*** 186 | Range: [1ms, 10s] 187 | Specifies the duration of thedecay stage, i.e. how long it takes the amplitude to fall back to the sustain level. 188 | 189 | ***Sustain*** 190 | Range: [0, 1] 191 | Specifies the sustain level that is reached after the decay stage ends. The sustain stage lasts as long as a key is pressed. 192 | 193 | ***Release*** 194 | Range: [1ms, 10s] 195 | This stage is reached whenever a key is released. This parameter specifies the duration it takes the envelope to hit zero. 196 | 197 | ## 3.4. LFOs 198 | 199 | Low Frequency Oscillators are similar to Oscillators but they ussally generate signal in inaudible range used to continually change aspects of sound, like modulating a volume or pitch. 200 | 201 | ![lfo][lfo] 202 | 203 | ***Wave*** 204 | Values: sine | sawtooth | triangle | square 205 | By dragging the waveform selector you can select of type to generate. 206 | 207 | ***Frequency*** 208 | Range: [0.2Hz, 10Hz] 209 | Change pitch of oscillator. 210 | 211 | ***Phase*** 212 | Range: [0, 360] 213 | Change start phase of waveform. This parameter does not have impact on wave, if *Retrig* button is disabled. 214 | 215 | ***Retrigger*** 216 | Values: on | off 217 | Force LFO to start at the exact same location every time a new note is triggered. That location can be changed using the *Phase* knob. 218 | 219 | ***Gain*** 220 | Values: [0, 1] 221 | Sets the amplitude of LFO signal. 222 | 223 | ## 3.5. Filter 224 | 225 | ![filter][filter] 226 | 227 | ***Type*** 228 | Values: LP12 | LP24 | BP12 | BP24 | HP12 | HP24 | NONE 229 | Specifies type of filter: 230 | - LP (low-pass) filter damps frequencies above cutoff frequency; 231 | - BP (band-pass) filter damps frequencies around the cutoff frequency; 232 | - HP (high-pass) filter damps frequencies passes signal above the cutoff frequency unchanged. 233 | - NONE - allpass filter and that allows all frequencies to pass. 234 | 235 | Numbers next to filter type specify filter's rolloff slope as `dB per octave`, 12dB/oct and 24dB/oct respectively. 236 | 237 | ***Cutoff*** 238 | Range: [20Hz, 20kHz] 239 | The most important parameter in the filter. Specifies corner frequency where filter operates. 240 | 241 | ***Resonance*** 242 | Range: [0, 10] 243 | Controls filter resonance at cutoff frequency. In Band-pass mode controls the width of the band. The width becomes narrower as the value increases. 244 | 245 | ***Envelope*** 246 | Range: [0, 1] 247 | Controls how much the filter envelope affects the cutoff frequency. Set to zero, the filter envelope has no effect on the cutoff frequency. At 1 the envelope spans the entire cutoff range. If you want to set envelope amount to negative value use [Modulation Matrix](#modulation-matrix) instead with *Envelope* amount set to zero. 248 | 249 | ## 3.6. Master 250 | Master section is the last part of audio path in synthesizer before send signal to your speakers. 251 | On the right side of the Master panel is a VU meter which measures the output level. The red color indicates the signal is too loud and you should turn the volume down to prevent clipping. 252 | 253 | ![master][master] 254 | 255 | ***Gain*** 256 | Range: [0, 1] 257 | Controls the overall volume of the entire synthesizer. 258 | 259 | ***FX*** 260 | Values: on | off 261 | Toggle [Master Effects](#39-master-effects) section. 262 | 263 | 264 | ## 3.7. Modulation Matrix 265 | 266 | ![matrix][matrix] 267 | 268 | Modulation Matrix allows to create more complex patches by linking selected source with one or multiple of many available parameters. Modulation Matrix offers 5 slot can be used to connect different parameters. 269 | All sources are converted to the same range: [0, 1] for unipolar and [-1, 1] for bipolar sources. The LFO's are bipolar, all other sources are unipolar. The current value of a source is **multiplied** with the *Amount* value [-1, 1] in the same modulation slot. The result of the multiplication is then **multiplied** again with selected parameter modulation range and **added** to destination value. 270 | 271 | ## 3.8. Oscilloscope 272 | 273 | ![scope][scope] 274 | 275 | Oscilloscope display is hidden by default behind Modulation Matrix and can be toggled by clicking on button placed in top right corner of Matrix section. It displays waveform shape on synthesizer output in real time. 276 | 277 | ## 3.9. Master Effects 278 | 279 | Master FX panel is hidden by default behind keyboard and can be open by clicking on *FX* button placed in [*Master*](#36-master) section of the synthesizer, next to [*Modulation Matrix*](#37-modulation-matrix). 280 | 281 | ![fx][fx] 282 | 283 | Each effect, besided effect control parameters, is shipped with **ON/OFF** button placed in top right corner of each effect panel and **WET** knob which controls effect depth and sets ratio between the wet and original input signal (except *Limiter* effect). A value of 0 will mute the effect, while a value of 1 will pass only the original - dry - signal. 284 | 285 | 286 | ### 3.9.1. Distortion 287 | Synth offer eight types of waveshaping distortion algorithms: 288 | - Sine; 289 | - Gloubi Boulga *(waveshaping algorithm by Laurent de Soras)*; 290 | - Fold Back; 291 | - Soft Clip; 292 | - Saturation; 293 | - Bit Crusher; 294 | - Tarrabia *(waveshaping algorithm by Partice Tarrabia and Bram de Jong)*; 295 | - Fuzz. 296 | 297 | Each algorithm offers different sound and harmonic content. Switch between them by clicking on display and selecting one from dropdown list. 298 | 299 | ***Amount*** 300 | Range: [0, 1] 301 | Controls the effect intensity. 302 | 303 | ### 3.9.2. Chorus 304 | Create effect that sounds like multiple instruments being played simultaneously. 305 | 306 | ***Delay*** 307 | Range: [2ms, 20ms] 308 | The output of chorus is a mix of the input signal with delayed copy of it. Controls the delay offset in miliseconds. 309 | 310 | ***Depth*** 311 | Range: [0, 1] 312 | Controls internal LFO modulation intensity *(amplitude)*. 313 | 314 | ***Feedback*** 315 | Range: [0, 1] 316 | Controls amount of signal from the output that returns back into the input of the effect, which can be used to create flanging effect. 317 | 318 | ***Fequency*** 319 | Range: [10Hz, 10kHz] 320 | Controls the frequency of the LFO which modulates the *Delay* paramter. 321 | 322 | ***Spread*** 323 | Range: [0, 180] 324 | Controls amount fo stereo spread. At 0, both LFO's will be panned centrally, while at 180, LFO's will be spread hard to left and right. 325 | 326 | ### 3.9.3. Delay 327 | Feedback delay effect that can be used to create echoing sound effects. 328 | 329 | ***Delay*** 330 | Range: [50ms, 1s] 331 | Controls delay time. 332 | 333 | ***Feedback*** 334 | Range: [0, 1] 335 | Controls the speed with which the delays will fade away. 336 | 337 | ### 3.9.4. Reverb 338 | The Reverb effect simulates sound reflections from surrounding walls or objects. Make sound more realistic and add depth to it. 339 | 340 | ***Size*** 341 | Range: [100ms, 5s] 342 | Controls the duration of effect. Larger values give perception of larger room. 343 | 344 | ***Predelay*** 345 | Range: [100ms, 5s] 346 | Adjusts the onset of the reverberated signal. Controls the amount of time before the reverb is fully ramped in. 347 | 348 | ### 3.9.5. Limiter 349 | Brickwall limiter which limits the loudness of incoming signal. 350 | 351 | ***Threshold*** 352 | Range: [-96dB, 0dB] 353 | Controls the level at which gain reduction begins. 354 | 355 | # 4. Tips on Reducing CPU Usage 356 | 357 | When using a lot of synthesizer and audio effect at the same time, CPU usage can become a problem. Here are some tips to reduce CPU usage: 358 | 359 | - **Envelope Generators** 360 | Try to keep the *Decay* and *Release* parameters as small as possible. Sound with smaller release will fade faster and use less polyphony voices. 361 | - **Effects** 362 | Disable any master effects you don't use. 363 | - **Modulation Matrix** 364 | Clear unused sources and destinations slots from matrix. Synthesizer core will not process these nodes, if you do not need them. Even if you set *AMOUNT* parameter to 0, Synth will still send signals to connected nodes. 365 | - **Disable oscilloscope** 366 | Oscilloscope is generated in canvas element using *requestAnimationFrame* Web API method that allows to create high performance visual content, but require some additional resources. Disabling this element can significantly reduce CPU usage. 367 | - **Reduce polyphony usage** 368 | Playing complex chords is fun and Synth give you possibility to play up to 8 notes at the same time, but remember, it is just JavaScript app, not highly optimized VST plugin designed to use in professional DAW's, but only in your web browser. 369 | 370 | If you still experience any problems with this app try to reload webpage or even close current tab and wait a minute or so to let your browser clear its memory and cache by its own. 371 | 372 | --- 373 | 374 | # 5. Technical Notes 375 | 376 | Tone.js is an awesome framework that offers a bunch of high quality components, but for this project I decided to tweak some of them a little to provide additional functionalities. 377 | This section explains (in short) technical differences, one section by one, between Tone.js components and their customized implementations used in this project. 378 | 379 | ## 5.1. Oscillator 380 | - add support for multiple detuning units, allow to tune oscillator easily to any note: 381 | - octave - control pitch in octaves, 382 | - seminote - control pitch in seminotes, 383 | - finetune - control pitch in cents. 384 | - allow to set voices number to zero and disable not used oscillator, 385 | - oscillator *free* phase mode - generate random phase for every new note, disable this effect, and sync all voices phase, by enabling *RETRIGGER* button. 386 | 387 | ## 5.2. LFO 388 | - add retrigger switch - start LFO from the same phase position each note. 389 | 390 | ## 5.3. Envelope 391 | - custom shape for decay curve - build in exponential curve sounds great with short decay values, its sharp and snappy, but used algorithm is - IMHO - *too* sharp. Enlarging decay value does not provide significant change in sound amplitude, signal fade very fast and reach sustain level very quickly. Project implementation use exponential function to shape linear curve in range [1, 0] slope which is next eased to fit dedicated range using this formula: 392 | 393 | 394 | 395 | where: 396 | x - signal value, 397 | u - upper signal value (1), 398 | l - lower signal value (which is *sustain* value in range [0, 1]) 399 | 400 | ![decayeg][decayeg] 401 | *Custom envelope shape in comparison to build-in Tone.js Envelope with exponential decay curve. Settings values: decay time=1.5s, sustain=0.5, hold time=2s*. 402 | \ 403 | Custom generated shape fades progressively in time and still does not sound as artificially as linear curve. 404 | 405 | ## 5.4. Master Effects 406 | - disable effect in effect chain - Tone.js, for most of their effects components, provide only one parameter to control effect intensity - *WET*. This parameter - however - does not prevent from using CPU usage, signal is still passed and processed by effect. Dedicated *ON/OFF* switch completely disconnect effect from audio chain and let orginal signal to pass through. 407 | 408 | ## 5.5. Modulation 409 | Tone.js (and Web Audio API in general) does not offer any native support of independent control for modulated parameter. Every parameter, connected to any modualation source, automatically recieve status ***overridden*** which prevents from any values change sent directly to this property. WAAPI architecture, however, allows to connect multiple signals to one property where all are summed together in parameter intput. Every modulatable parameter has been wrapped in custom *MODULATION* function that provides dedicated input for constant source value and modulation output. This way it has become possible to control parameter base value independly from any modulation source connected to it. 410 | 411 | The simplified implementation is presented on this diagram: 412 | ``` 413 | Parameter Signal 414 | ↘ 415 | (+) -→ Parameter 416 | ↗ 417 | Modulation Signals 418 | ``` 419 | 420 | Parameter can receive any modulation signal and sill be able to update its base value. 421 | 422 | ## 5.6. Voice Management 423 | Due to static and rigid nature of components relationships, I have decided to drop down voice management system that Tone.js *PolySynth* object has to offer, i.e. dynamic voice creation and removal of allocated voices. In current implementation all polyphonic voices are initialized only once at start and allocated dynamically during playtime. This way synthesizer can avoid potential latency time problems caused by expensive voice creation/initialization or removal, but at the overall cost of synthesizer initialization time. 424 | 425 | 426 | # 6. References and inspiration 427 | 428 | - [Tone.js](https://tonejs.github.io/) 429 | - [Sound on Sound - Synth Secrets](https://www.soundonsound.com/techniques/whats-sound) 430 | - [Developing a Digital Synthesizer in C++ by Peter Goldsborough](https://issuu.com/petergoldsborough/docs/thesis_3c166f78d5673b) 431 | - [Musicdsp.org](https://www.musicdsp.org/en/latest/index.html) 432 | - [Making Audio Plugins - Martin Finke's Blog](http://www.martin-finke.de/blog/articles/) 433 | 434 | 435 | 436 | [1]: https://www.soundonsound.com/techniques/polyphony-digital-synths 437 | 438 | 439 | [synth]: ../media/synth.png?raw=true 440 | [diagram]: ../media/Diagram.png?raw=true 441 | [knobControl]: ../media/controls.png?raw=true 442 | [preset]: ../media/preset-display.png?raw=true 443 | [eg]: ../media/eg.png?raw=true 444 | [filter]: ../media/filter.png?raw=true 445 | [matrix]: ../media/matrix.png?raw=true 446 | [lfo]: ../media/lfo.png?raw=true 447 | [mixer]: ../media/mixer.png?raw=true 448 | [master]: ../media/master.png?raw=true 449 | [oscillator]: ../media/oscillator.png?raw=true 450 | [scope]: ../media/scope.png?raw=true 451 | [fx]: ../media/fx.png?raw=true 452 | [decayeg]: ../media/decayeg.png?raw=true 453 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "allowJs": true, 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@/*": ["./src/*"] 7 | } 8 | }, 9 | 10 | "exclude": ["node_modules"] 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-synth", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "test:unit": "vue-cli-service test:unit" 9 | }, 10 | "dependencies": { 11 | "core-js": "^3.6.5", 12 | "lodash.merge": "^4.6.2", 13 | "tone": "^14.7.68", 14 | "vue": "^2.6.11" 15 | }, 16 | "devDependencies": { 17 | "@vue/cli-plugin-babel": "^4.5.7", 18 | "@vue/cli-plugin-unit-jest": "^4.5.7", 19 | "@vue/cli-service": "^4.5.7", 20 | "@vue/test-utils": "^1.0.3", 21 | "node-sass": "^4.12.0", 22 | "sass-loader": "^8.0.2", 23 | "vue-template-compiler": "^2.6.11" 24 | }, 25 | "browserslist": [ 26 | "> 1%", 27 | "last 2 versions", 28 | "not dead" 29 | ], 30 | "jest": { 31 | "preset": "@vue/cli-plugin-unit-jest" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Razz21/vue-synth/0069cf46366d12140b085f4809f161f5f0e845c1/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | 17 | 20 | -------------------------------------------------------------------------------- /src/Synth/Effects.js: -------------------------------------------------------------------------------- 1 | import { 2 | optionsFromArguments, 3 | Gain, 4 | ToneAudioNode, 5 | Chorus, 6 | Reverb, 7 | FeedbackDelay, 8 | Limiter 9 | } from "tone"; 10 | 11 | import { FX, DISTORTION } from "@/Synth/nodes"; 12 | import { CONTROLLERS, CONTROL_TYPES, DISTORTION_ALGORITHMS } from "@/Synth/synthfunctions"; 13 | import { FXCommand, FXBypassCommand } from "@/Synth/commands"; 14 | 15 | export class EffectModule extends ToneAudioNode { 16 | constructor() { 17 | super(optionsFromArguments(EffectModule.getDefaults(), arguments)); 18 | const options = optionsFromArguments(EffectModule.getDefaults(), arguments); 19 | this.effects = new Array(); 20 | let i, e; 21 | for (i = 0; i < options.effects.length; i++) { 22 | e = options.effects[i]; 23 | 24 | this.effects.push(new FX({ effect: e.effect, options: e.options })); 25 | // if (e.effect instanceof ToneAudioNode) {} // TODO check valid effect constructor 26 | } 27 | this.input = new Gain(1); 28 | this.output = new Gain(1); 29 | this.input.chain(...this.effects, this.output); 30 | } 31 | 32 | static getDefaults() { 33 | return Object.assign(ToneAudioNode.getDefaults(), { 34 | effects: [ 35 | { effect: DISTORTION, options: {} }, 36 | { effect: Chorus, options: {} }, 37 | { effect: FeedbackDelay, options: {} }, 38 | { effect: Reverb, options: {} }, 39 | { effect: Limiter, options: {} } 40 | ] 41 | }); 42 | } 43 | 44 | setFX(id, options) { 45 | const fx = this.effects[id]; 46 | if (!fx) return; 47 | fx.set(options); 48 | } 49 | 50 | dispose() { 51 | super.dispose(); 52 | this.effects.forEach(i => { 53 | i.disconnect(); 54 | i.dispose(); 55 | }); 56 | return this; 57 | } 58 | 59 | async *getControls() { 60 | const controls = EffectControls(); 61 | var i; 62 | for (i = controls.length; i--; ) { 63 | yield controls[i]; 64 | } 65 | } 66 | } 67 | 68 | const names = [ 69 | "sine", 70 | "gloubiboulga", 71 | "foldback", 72 | "softClip", 73 | "saturator", 74 | "crusher", 75 | "tarrabia", 76 | "fuzz" 77 | ]; 78 | 79 | function EffectControls() { 80 | const controls = []; 81 | let control; 82 | // ------------- distortion -------------- 83 | // type ------------------ 84 | control = { 85 | options: { 86 | type: CONTROL_TYPES.SELECT, 87 | id: CONTROLLERS.FX1_1, 88 | step: 1, 89 | snap: true, 90 | max: names.length - 1, 91 | items: names, 92 | label: "type", 93 | digits: 0 94 | }, 95 | command: { 96 | type: FXCommand, 97 | args: [0, "type"] 98 | } 99 | }; 100 | controls.push(control); 101 | // amt ------------------ 102 | control = { 103 | options: { 104 | type: CONTROL_TYPES.KNOB, 105 | id: CONTROLLERS.FX1_2, 106 | min: 0, 107 | max: 1, 108 | label: "amt" 109 | }, 110 | command: { 111 | type: FXCommand, 112 | args: [0, "distortion"] 113 | } 114 | }; 115 | controls.push(control); 116 | 117 | // ------------- /distortion -------------- 118 | // ------------- chorus -------------- 119 | // delayTime ------------------ 120 | control = { 121 | options: { 122 | type: CONTROL_TYPES.KNOB, 123 | id: CONTROLLERS.FX2_1, 124 | min: 2, 125 | max: 20, 126 | default: 10, 127 | label: "delay", 128 | units: "ms" 129 | }, 130 | command: { 131 | type: FXCommand, 132 | args: [1, "delayTime"] 133 | } 134 | }; 135 | controls.push(control); 136 | // depth ------------------ 137 | control = { 138 | options: { 139 | type: CONTROL_TYPES.KNOB, 140 | id: CONTROLLERS.FX2_2, 141 | min: 0, 142 | max: 1, 143 | label: "depth" 144 | }, 145 | command: { 146 | type: FXCommand, 147 | args: [1, "depth"] 148 | } 149 | }; 150 | controls.push(control); 151 | // feedback ------------------ 152 | control = { 153 | options: { 154 | type: CONTROL_TYPES.KNOB, 155 | id: CONTROLLERS.FX2_3, 156 | min: 0, 157 | max: 1, 158 | label: "feedback" 159 | }, 160 | command: { 161 | type: FXCommand, 162 | args: [1, "feedback"] 163 | } 164 | }; 165 | controls.push(control); 166 | // frequency ------------------ 167 | control = { 168 | options: { 169 | type: CONTROL_TYPES.KNOB, 170 | id: CONTROLLERS.FX2_4, 171 | min: 0.1, 172 | max: 10, 173 | default: 1, 174 | label: "freq", 175 | units: "Hz" 176 | }, 177 | command: { 178 | type: FXCommand, 179 | args: [1, "frequency"] 180 | } 181 | }; 182 | controls.push(control); 183 | // frequency ------------------ 184 | control = { 185 | options: { 186 | type: CONTROL_TYPES.KNOB, 187 | id: CONTROLLERS.FX2_5, 188 | min: 0, 189 | max: 180, 190 | default: 0, 191 | label: "spread" 192 | }, 193 | command: { 194 | type: FXCommand, 195 | args: [1, "spread"] 196 | } 197 | }; 198 | controls.push(control); 199 | // ------------- /chorus -------------- 200 | // -------------- delay --------------- 201 | // delayTime ------------------ 202 | control = { 203 | options: { 204 | type: CONTROL_TYPES.KNOB, 205 | id: CONTROLLERS.FX3_1, 206 | min: 0.05, 207 | max: 1, 208 | default: 0.5, 209 | label: "delay", 210 | units: "sec" 211 | }, 212 | command: { 213 | type: FXCommand, 214 | args: [2, "delayTime"] 215 | } 216 | }; 217 | controls.push(control); 218 | // feedback ------------------ 219 | control = { 220 | options: { 221 | type: CONTROL_TYPES.KNOB, 222 | id: CONTROLLERS.FX3_2, 223 | min: 0, 224 | max: 1, 225 | default: 0.5, 226 | label: "feedback" 227 | }, 228 | command: { 229 | type: FXCommand, 230 | args: [2, "feedback"] 231 | } 232 | }; 233 | controls.push(control); 234 | // -------------- /delay -------------- 235 | // ------------- reverb --------------- 236 | // decay/size --------------- 237 | control = { 238 | options: { 239 | type: CONTROL_TYPES.KNOB, 240 | id: CONTROLLERS.FX4_1, 241 | min: 0.1, 242 | max: 5, 243 | default: 0.5, 244 | label: "size", 245 | units: "sec" 246 | }, 247 | command: { 248 | type: FXCommand, 249 | args: [2, "decay"] 250 | } 251 | }; 252 | controls.push(control); 253 | // preDelay --------------- 254 | control = { 255 | options: { 256 | type: CONTROL_TYPES.KNOB, 257 | id: CONTROLLERS.FX4_2, 258 | min: 0.001, 259 | max: 0.3, 260 | default: 0.015, 261 | label: "predelay", 262 | units: "sec", 263 | exp: 3, 264 | digits: 3 265 | }, 266 | command: { 267 | type: FXCommand, 268 | args: [2, "preDelay"] 269 | } 270 | }; 271 | controls.push(control); 272 | // ------------- /reverb -------------- 273 | // ------------- limiter -------------- 274 | // threshold ------------- 275 | control = { 276 | options: { 277 | type: CONTROL_TYPES.KNOB, 278 | id: CONTROLLERS.FX5_1, 279 | min: -96, 280 | max: 0, 281 | default: 0, 282 | label: "threshold", 283 | units: "dB", 284 | exp: 0.1 285 | }, 286 | command: { 287 | type: FXCommand, 288 | args: [4, "threshold"] 289 | } 290 | }; 291 | controls.push(control); 292 | // on/off ------------- 293 | control = { 294 | options: { 295 | type: CONTROL_TYPES.TOGGLE, 296 | id: CONTROLLERS.FX5_ON, 297 | label: "ON/OFF", 298 | digits: 0 299 | }, 300 | command: { 301 | type: FXBypassCommand, 302 | args: [4] 303 | } 304 | }; 305 | controls.push(control); 306 | // ------------- /limiter -------------- 307 | 308 | // for each ------------- 309 | for (let i = 0; i < 4; i++) { 310 | // on/off ------------- 311 | control = { 312 | options: { 313 | type: CONTROL_TYPES.TOGGLE, 314 | id: CONTROLLERS[`FX${i + 1}_ON`], 315 | label: "ON/OFF" 316 | }, 317 | command: { 318 | type: FXBypassCommand, 319 | args: [i] 320 | } 321 | }; 322 | controls.push(control); 323 | // wet ------------------ 324 | control = { 325 | options: { 326 | type: CONTROL_TYPES.KNOB, 327 | id: CONTROLLERS[`FX${i + 1}_WET`], 328 | min: 0, 329 | max: 1, 330 | label: "wet", 331 | default: 1 332 | }, 333 | command: { 334 | type: FXCommand, 335 | args: [i, "wet"] 336 | } 337 | }; 338 | controls.push(control); 339 | } 340 | return controls; 341 | } 342 | -------------------------------------------------------------------------------- /src/Synth/ModMatrix.js: -------------------------------------------------------------------------------- 1 | import { SOURCES, DESTINATIONS } from "./synthfunctions"; 2 | import { ToneAudioNode, Multiply, optionsFromArguments } from "tone"; 3 | import { Command, MatrixCommand } from "@/Synth/commands"; 4 | 5 | /* 6 | =========================================== 7 | */ 8 | 9 | export class Modulator extends ToneAudioNode { 10 | constructor() { 11 | super(optionsFromArguments(Modulator.getDefaults(), arguments)); 12 | const options = optionsFromArguments(Modulator.getDefaults(), arguments); 13 | this._source = options.source; 14 | this._destination = options.destination; 15 | this.signalNode = this.input = this.output = new Multiply({ 16 | value: options.amount, 17 | type: options.type, 18 | context: this.context 19 | }); 20 | } 21 | 22 | static getDefaults() { 23 | return Object.assign(ToneAudioNode.getDefaults(), { 24 | source: null, 25 | destination: null, 26 | type: "audiorange", 27 | amount: 0 28 | }); 29 | } 30 | 31 | get amount() { 32 | return this.signalNode.factor.value; 33 | } 34 | 35 | set amount(val) { 36 | this.signalNode.factor.value = val; 37 | } 38 | 39 | /* 40 | control source node 41 | */ 42 | setSource(node) { 43 | if (this._source === node) return; 44 | if (this._source) { 45 | this._source.disconnect(this.signalNode); 46 | this._source = null; 47 | } 48 | if (node) { 49 | this._source = node; 50 | this._source.connect(this.signalNode); 51 | } 52 | } 53 | 54 | /* 55 | control destination node 56 | */ 57 | setDestination(node) { 58 | if (this._destination === node) return; 59 | this.signalNode.disconnect(); 60 | if (node) { 61 | this._destination = node; 62 | this.signalNode.connect(this._destination); 63 | } 64 | } 65 | get source() { 66 | return this._source; 67 | } 68 | 69 | set source(node) { 70 | if (this._source === node) return; 71 | if (this._source) { 72 | this._source.disconnect(this.signalNode); 73 | this._source = null; 74 | } 75 | if (node) { 76 | this._source = node; 77 | this._source.connect(this.signalNode); 78 | } 79 | } 80 | 81 | get destination() { 82 | return this._destination; 83 | } 84 | 85 | set destination(node) { 86 | if (this._destination === node) return; 87 | this.signalNode.disconnect(); 88 | if (node) { 89 | this._destination = node; 90 | this.signalNode.connect(this._destination); 91 | } 92 | } 93 | 94 | dispose() { 95 | super.dispose(); 96 | this.source && this.source.disconnect(this.signalNode); 97 | this.signalNode.disconnect(); 98 | this.signalNode.dispose(); 99 | this.source = null; 100 | this.destination = null; 101 | } 102 | } 103 | 104 | export class MatrixRow { 105 | constructor() { 106 | const options = optionsFromArguments(MatrixRow.getDefaults(), arguments); 107 | 108 | this._id = options.id; 109 | this._source = options.source; 110 | this._destination = options.destination; 111 | // for GUI -> control 112 | this._amount = options.amount; 113 | this._command = null; 114 | } 115 | 116 | static getDefaults() { 117 | return { source: 0, destination: 0, value: 0 }; 118 | } 119 | 120 | setCommand(command) { 121 | if (command instanceof Command) { 122 | this._command = command; 123 | } 124 | } 125 | 126 | set(values) { 127 | if (Array.isArray(values)) { 128 | this.source = values[0] || 0; 129 | this.destination = values[1] || 0; 130 | this.amount = values[2] || 0; 131 | } 132 | } 133 | 134 | getValues() { 135 | return [this.source, this.destination, this.value]; 136 | } 137 | 138 | execute(options) { 139 | if (!this._command) return; 140 | this._command.execute({ id: this.id, ...options }); 141 | } 142 | 143 | get amount() { 144 | return this._amount; 145 | } 146 | 147 | set amount(amount) { 148 | this._amount = amount; 149 | this.execute({ value }); 150 | } 151 | 152 | get id() { 153 | return this._id; 154 | } 155 | 156 | get source() { 157 | return this._source; 158 | } 159 | 160 | set source(source) { 161 | if (this.source !== source) { 162 | this._source = source; 163 | this.execute({ source }); 164 | } 165 | } 166 | 167 | get destination() { 168 | return this._destination; 169 | } 170 | 171 | set destination(destination) { 172 | if (this.destination !== destination) { 173 | this._destination = destination; 174 | this.execute({ destination }); 175 | } 176 | } 177 | 178 | dispose() { 179 | this.source = 0; 180 | this.destination = 0; 181 | this.value = 0; 182 | } 183 | } 184 | 185 | export class ModMatrix { 186 | constructor() { 187 | // available slots 188 | this._slots = 5; 189 | this.size = 0; 190 | this.matrixCore = null; 191 | this.sources = new Array(SOURCES.MAX_SOURCES); 192 | this.destinations = new Array(DESTINATIONS.MAX_DESTINATIONS); 193 | this.modulators = null; 194 | } 195 | 196 | /** 197 | * initialize matrix with empty modulation slots 198 | * 199 | * @param {ModMatrix} modMatrix 200 | * @returns {void} 201 | */ 202 | 203 | initializeModMatrix(modMatrix, context) { 204 | // const command = new MatrixCommand(context); 205 | // for (let i = 0; i < this._slots; i++) { 206 | // const row = new MatrixRow({ id: i }); 207 | // row.setCommand(command); 208 | // modMatrix.addModMatrixRow(row, i); 209 | // } 210 | } 211 | 212 | initializeModulators() { 213 | this.modulators = new Array(this._slots); 214 | for (let i = 0; i < this._slots; i++) { 215 | this.modulators[i] = new Modulator(); 216 | } 217 | } 218 | 219 | setRows(matrix, rows) { 220 | if (!matrix.matrixCore || !Array.isArray(rows)) return; 221 | matrix.matrixCore.map(row => { 222 | row.set(rows[i]); 223 | }); 224 | } 225 | 226 | getRows() { 227 | if (!this.matrixCore) return []; 228 | return this.matrixCore.reduce((acc, row) => { 229 | const values = row.getValues(); 230 | acc.push(values); 231 | return acc; 232 | }, []); 233 | } 234 | 235 | set({ id, value }) { 236 | if (!this.modulators) return; 237 | const mod = this.modulators[id]; 238 | if (!mod) return; 239 | const source = this.sources[value[0]], 240 | destination = this.destinations[value[1]], 241 | amount = value[2]; 242 | 243 | mod.source = source; 244 | mod.destination = destination; 245 | mod.amount = amount; 246 | } 247 | 248 | _setSource(mod, idx) { 249 | const source = this.sources[idx]; 250 | mod.setSource(source); 251 | } 252 | 253 | _setDestination(mod, idx) { 254 | const destination = this.destinations[idx]; 255 | mod.setDestination(destination); 256 | } 257 | 258 | _setValue(mod, value) { 259 | mod.value = value; 260 | } 261 | 262 | getMatrixCore() { 263 | return this.matrixCore; 264 | } 265 | 266 | setMatrixCore(modMatrix) { 267 | this.matrixCore = modMatrix; 268 | this.size = this.getMatrixSize(); 269 | } 270 | 271 | getMatrixSize() { 272 | let size = 0; 273 | if (!this.matrixCore) return size; 274 | for (let i = 0; i < this.matrixCore.length; i++) { 275 | const row = this.matrixCore[i]; 276 | if (row) size++; 277 | } 278 | return size; 279 | } 280 | 281 | clearMatrix() { 282 | if (!this.matrixCore) return; 283 | let i; 284 | for (i = this.matrixCore.length; i--; ) { 285 | this.matrixCore[i] = null; 286 | } 287 | } 288 | 289 | addModMatrixRow(row, idx) { 290 | if (!this.matrixCore) { 291 | this.createMatrixCore(); 292 | } 293 | this.matrixCore[idx] = row; 294 | this.size++; 295 | } 296 | 297 | createMatrixCore() { 298 | // create fixed length array 299 | this.matrixCore = Object.seal(new Array(this._slots).fill(null)); 300 | } 301 | 302 | deleteModMatrix() { 303 | this.matrixCore = null; 304 | this.size = 0; 305 | } 306 | 307 | deleteModulators() { 308 | if (!this.modulators) return; 309 | for (let i = 0; i < this.modulators.length; i++) { 310 | const modulator = this.modulators[i]; 311 | modulator.dispose(); 312 | } 313 | this.modulators = null; 314 | } 315 | 316 | dispose() { 317 | this.deleteModulators(); 318 | this.deleteModMatrix(); 319 | 320 | this.sources = null; 321 | this.destinations = null; 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /src/Synth/PresetManager.js: -------------------------------------------------------------------------------- 1 | export class PresetManager { 2 | constructor(context, presets = []) { 3 | this._presets = []; 4 | this._current = 0; 5 | this.context = context; 6 | this._addPresets(presets); 7 | } 8 | 9 | get currentPreset() { 10 | return this._presets[this.current]; 11 | } 12 | 13 | changePreset(id) { 14 | if (id === this.current) return; 15 | const preset = this._presets[id]; 16 | if (!preset) return; 17 | this.current = id; 18 | } 19 | 20 | get length() { 21 | return this._presets.length; 22 | } 23 | 24 | get current() { 25 | return this._current; 26 | } 27 | 28 | set current(value) { 29 | if (this._presets[value]) { 30 | this._current = value; 31 | this.context.setPreset(this.currentPreset); 32 | } 33 | } 34 | 35 | /** 36 | * iterate to next preset forward 37 | * @param {any} back 38 | */ 39 | nextPreset() { 40 | this.current = (this.current + 1) % this.length; 41 | } 42 | 43 | prevPreset() { 44 | this.current = (this.current + this.length - 1) % this.length; 45 | } 46 | 47 | /** 48 | * Reset current preset to initial state 49 | */ 50 | resetPreset() { 51 | this.current = this.current; 52 | } 53 | 54 | _addPresets(presets) { 55 | if (Array.isArray(presets)) { 56 | for (let i = 0; i < presets.length; i++) { 57 | this.addPreset(presets[i]); 58 | } 59 | } 60 | this.addPreset(initPreset); 61 | } 62 | 63 | addPreset({ name, values }) { 64 | if ( 65 | !name || 66 | !typeof name === "string" || 67 | (!values instanceof Object && values.constructor === Object) 68 | ) 69 | return; 70 | // initialize with basic EG / master gain / filter values 71 | values = Object.assign({}, initPreset.values, values); 72 | const newPreset = { name, values, id: this.length }; 73 | this._presets.push(newPreset); 74 | } 75 | } 76 | 77 | /* ///////////////////////// 78 | PRESETS 79 | ///////////////////////// */ 80 | 81 | const emptyPreset = { 82 | name: "empty", 83 | values: { 84 | MASTER_GAIN: 0.5, 85 | AMP_EG_ATT: 0, 86 | AMP_EG_DEC: 0, 87 | AMP_EG_REL: 0, 88 | AMP_EG_SUS: 0, 89 | EG_1_ATT: 0, 90 | EG_1_DEC: 0, 91 | EG_1_REL: 0, 92 | EG_1_SUS: 0, 93 | FILTER_CUT: 0, 94 | FILTER_EG: 0, 95 | FILTER_Q: 0, 96 | FILTER_TP: 0, 97 | FX1_1: 0, 98 | FX1_2: 0, 99 | FX1_ON: 0, 100 | FX1_WET: 0, 101 | FX2_1: 0, 102 | FX2_2: 0, 103 | FX2_3: 0, 104 | FX2_4: 0, 105 | FX2_5: 0, 106 | FX2_ON: 0, 107 | FX2_WET: 0, 108 | FX3_1: 0, 109 | FX3_2: 0, 110 | FX3_ON: 0, 111 | FX3_WET: 0, 112 | FX4_1: 0, 113 | FX4_2: 0, 114 | FX4_ON: 0, 115 | FX4_WET: 0, 116 | FX5_1: 0, 117 | FX5_ON: 0, 118 | LFO_1_FC: 0, 119 | LFO_1_GAIN: 0, 120 | LFO_1_PHASE: 0, 121 | LFO_1_RETR: 0, 122 | LFO_1_TP: 0, 123 | LFO_2_FC: 0, 124 | LFO_2_GAIN: 0, 125 | LFO_2_PHASE: 0, 126 | LFO_2_RETR: 0, 127 | LFO_2_TP: 0, 128 | M_ROW_1: [0, 0, 0], 129 | M_ROW_2: [0, 0, 0], 130 | M_ROW_3: [0, 0, 0], 131 | M_ROW_4: [0, 0, 0], 132 | M_ROW_5: [0, 0, 0], 133 | OSC_1_FINE: 0, 134 | OSC_1_OCT: 0, 135 | OSC_1_PAN: 0, 136 | OSC_1_PHASE: 0, 137 | OSC_1_RETR: 0, 138 | OSC_1_SEMI: 0, 139 | OSC_1_SPRD: 0, 140 | OSC_1_TP: 0, 141 | OSC_1_VOICE: 1, 142 | OSC_1_VOL: 0, 143 | OSC_2_FINE: 0, 144 | OSC_2_OCT: 0, 145 | OSC_2_PAN: 0, 146 | OSC_2_PHASE: 0, 147 | OSC_2_RETR: 0, 148 | OSC_2_SEMI: 0, 149 | OSC_2_SPRD: 0, 150 | OSC_2_TP: 0, 151 | OSC_2_VOICE: 0, 152 | OSC_2_VOL: 0, 153 | OSC_3_PAN: 0, 154 | OSC_3_VOL: 0 155 | } 156 | }; 157 | 158 | const initPreset = { 159 | name: "Init", 160 | values: { 161 | MASTER_GAIN: 0.5, 162 | AMP_EG_ATT: 0.02, 163 | AMP_EG_DEC: 0.2, 164 | AMP_EG_REL: 0.25, 165 | AMP_EG_SUS: 0.5, 166 | EG_1_ATT: 0.001, 167 | EG_1_DEC: 0.1, 168 | EG_1_REL: 0.2, 169 | EG_1_SUS: 0.5, 170 | FILTER_CUT: 10000, 171 | FILTER_EG: 0, 172 | FILTER_Q: 0, 173 | FILTER_TP: 0, 174 | FX1_1: 0, 175 | FX1_2: 0, 176 | FX1_ON: 0, 177 | FX1_WET: 0, 178 | FX2_1: 0, 179 | FX2_2: 0, 180 | FX2_3: 0, 181 | FX2_4: 0, 182 | FX2_5: 0, 183 | FX2_ON: 0, 184 | FX2_WET: 0, 185 | FX3_1: 0, 186 | FX3_2: 0, 187 | FX3_ON: 0, 188 | FX3_WET: 0, 189 | FX4_1: 0, 190 | FX4_2: 0, 191 | FX4_ON: 0, 192 | FX4_WET: 0, 193 | FX5_1: 0, 194 | FX5_ON: 0, 195 | LFO_1_FC: 0, 196 | LFO_1_GAIN: 0, 197 | LFO_1_PHASE: 0, 198 | LFO_1_RETR: 0, 199 | LFO_1_TP: 0, 200 | LFO_2_FC: 0, 201 | LFO_2_GAIN: 0, 202 | LFO_2_PHASE: 0, 203 | LFO_2_RETR: 0, 204 | LFO_2_TP: 0, 205 | M_ROW_1: [0, 0, 0], 206 | M_ROW_2: [0, 0, 0], 207 | M_ROW_3: [0, 0, 0], 208 | M_ROW_4: [0, 0, 0], 209 | M_ROW_5: [0, 0, 0], 210 | OSC_1_FINE: 0, 211 | OSC_1_OCT: 0, 212 | OSC_1_PAN: 0, 213 | OSC_1_PHASE: 0, 214 | OSC_1_RETR: 0, 215 | OSC_1_SEMI: 0, 216 | OSC_1_SPRD: 0, 217 | OSC_1_TP: 0, 218 | OSC_1_VOICE: 1, 219 | OSC_1_VOL: 1, 220 | OSC_2_FINE: 0, 221 | OSC_2_OCT: 0, 222 | OSC_2_PAN: 0, 223 | OSC_2_PHASE: 0, 224 | OSC_2_RETR: 0, 225 | OSC_2_SEMI: 0, 226 | OSC_2_SPRD: 0, 227 | OSC_2_TP: 0, 228 | OSC_2_VOICE: 0, 229 | OSC_2_VOL: 0, 230 | OSC_3_PAN: 0, 231 | OSC_3_VOL: 0 232 | } 233 | }; 234 | -------------------------------------------------------------------------------- /src/Synth/Voice.js: -------------------------------------------------------------------------------- 1 | import { Monophonic } from "tone/build/esm/instrument/Monophonic"; 2 | 3 | import { 4 | PolySynth, 5 | MidiClass, 6 | optionsFromArguments, 7 | Panner, 8 | FrequencyClass, 9 | Noise, 10 | Gain 11 | } from "tone"; 12 | 13 | import { ModMatrix } from "./ModMatrix"; 14 | import { DESTINATIONS, SOURCES } from "./synthfunctions"; 15 | import { VCO, LFO, FILTER, MODULATION, ENVELOPE } from "./nodes"; 16 | import { Modulator } from "@/Synth/ModMatrix"; 17 | import * as CMD from "@/Synth/commands"; 18 | import { CONTROLLERS, CONTROL_TYPES } from "@/Synth/synthfunctions"; 19 | import { FILTERTypes, OSCWaves, LFOWaves } from "@/Synth/globals"; 20 | export class Voice extends Monophonic { 21 | constructor() { 22 | super(optionsFromArguments(Voice.getDefaults(), arguments)); 23 | const options = optionsFromArguments(Voice.getDefaults(), arguments); 24 | this._isActive = false; 25 | this._noteNumber = -1; 26 | 27 | // oscillators 28 | this.oscillator1 = new VCO( 29 | Object.assign( 30 | { context: this.context, onstop: () => this.onsilence(this) }, 31 | options.oscillator 32 | ) 33 | ); 34 | this.oscillator2 = new VCO(options.oscillator); 35 | this.noise = new Noise(options.noise); 36 | 37 | this.oscillator1.volume.value = this.oscillator2.volume.value = this.noise.volume.value = 6; 38 | 39 | // volume oscillator nodes 40 | this.gain1 = new Gain(); 41 | this.gain2 = new Gain(); 42 | this.gain3 = new Gain(); 43 | 44 | this.gain123Node = new MODULATION({ units: "number", value: 0, modRange: 1 }); 45 | this.gain123Node.output.fan(this.gain1.gain, this.gain2.gain, this.gain3.gain); 46 | 47 | this.pitch12Node = new MODULATION({ exp: 3, units: "cents", value: 0, modRange: 4800 }); 48 | this.pitch12Node.output.fan(this.oscillator1.detune, this.oscillator2.detune); 49 | 50 | // oscillators mixer nodes -> pan/vol 51 | this.pan1 = new Panner(options.panner); 52 | this.pan2 = new Panner(options.panner); 53 | this.pan3 = new Panner(options.panner); 54 | 55 | this.AMP = new Gain(1); 56 | this.AMP_EG = new ENVELOPE(Object.assign(options.EG, { context: this.context })); 57 | this.EG_1 = new ENVELOPE(options.EG, { context: this.context }); 58 | 59 | // Filter 60 | this.filter = new FILTER(Object.assign(options.filter)); 61 | 62 | // modulators //---per voice 63 | this.LFO_1 = new LFO(options.LFO, { context: this.context }); 64 | this.LFO_2 = new LFO(options.LFO, { context: this.context }); 65 | 66 | // direct FilterEG -> filter cutoff modulation 67 | this.filterEGNode = new Modulator({ type: "normalrange", value: options.filterEG }); 68 | 69 | // connect nodes 70 | this.AMP_EG.connect(this.AMP.gain); 71 | this.EG_1.chain(this.filterEGNode, this.filter.cutoffNode.modNode); 72 | 73 | this.oscillator1.chain(this.gain1, this.pan1, this.filter); 74 | this.oscillator2.chain(this.gain2, this.pan2, this.filter); 75 | this.noise.chain(this.gain3, this.pan3, this.filter); 76 | this.filter.chain(this.AMP, this.output); 77 | 78 | // mod matrix 79 | this.modMatrix = new ModMatrix(); 80 | this._prepareForPlay(); 81 | 82 | // start LFO immediately 83 | } 84 | 85 | static getDefaults() { 86 | return Object.assign(Monophonic.getDefaults(), { 87 | filter: { 88 | frequency: 2000 89 | }, 90 | EG: { attack: 0.001, decay: 0.1, sustain: 0, release: 0.5 }, 91 | filterEG: 0, 92 | oscillator: { 93 | type: "sine" 94 | }, 95 | noise: { 96 | type: "white" 97 | }, 98 | LFO: {} 99 | }); 100 | } 101 | 102 | set filterEG(value) { 103 | this.filterEGNode.amount = value; 104 | } 105 | 106 | get filterEG() { 107 | return this.filterEGNode.amount; 108 | } 109 | 110 | get isActive() { 111 | return this._isActive; 112 | } 113 | 114 | set isActive(value) { 115 | this._isActive = Boolean(value); 116 | } 117 | 118 | _prepareForPlay() { 119 | // destinations 120 | this.modMatrix.destinations[DESTINATIONS.OSC_1_PITCH] = this.oscillator1.detuneNode.modNode; 121 | this.modMatrix.destinations[DESTINATIONS.OSC_1_VOL] = this.gain1.gain; 122 | this.modMatrix.destinations[DESTINATIONS.OSC_1_PAN] = this.pan1.pan; 123 | 124 | this.modMatrix.destinations[DESTINATIONS.OSC_2_PITCH] = this.oscillator2.detuneNode.modNode; 125 | this.modMatrix.destinations[DESTINATIONS.OSC_2_VOL] = this.gain2.gain; 126 | this.modMatrix.destinations[DESTINATIONS.OSC_2_PAN] = this.pan2.pan; 127 | 128 | this.modMatrix.destinations[DESTINATIONS.OSC_3_VOL] = this.gain3.gain; 129 | this.modMatrix.destinations[DESTINATIONS.OSC_3_PAN] = this.pan3.pan; 130 | this.modMatrix.destinations[DESTINATIONS.OSC_123_VOL] = this.gain123Node.modNode; 131 | this.modMatrix.destinations[DESTINATIONS.OSC_12_PITCH] = this.pitch12Node.modNode; 132 | 133 | this.modMatrix.destinations[DESTINATIONS.FILTER_CUT] = this.filter.cutoffNode.modNode; 134 | 135 | // sources 136 | this.modMatrix.sources[SOURCES.LFO_1_OUT] = this.LFO_1; 137 | this.modMatrix.sources[SOURCES.LFO_2_OUT] = this.LFO_2; 138 | this.modMatrix.sources[SOURCES.EG_1_OUT] = this.EG_1; 139 | this.modMatrix.sources[SOURCES.AMP_EG_OUT] = this.AMP_EG; 140 | } 141 | 142 | setNote(note, time) { 143 | const computedFrequency = note instanceof FrequencyClass ? note.toFrequency() : note; 144 | if (this.portamento > 0 && this.getLevelAtTime(computedTime) > 0.05) { 145 | const portTime = this.toSeconds(this.portamento); 146 | this.oscillator1.frequency.exponentialRampTo(computedFrequency, portTime, time); 147 | this.oscillator2.frequency.exponentialRampTo(computedFrequency, portTime, time); 148 | } else { 149 | this.oscillator1.frequency.setValueAtTime(note, time); 150 | this.oscillator2.frequency.setValueAtTime(note, time); 151 | } 152 | return this; 153 | } 154 | 155 | _triggerEnvelopeAttack(time, velocity = 1) { 156 | this.AMP_EG.triggerAttack(time, velocity); 157 | this.EG_1.triggerAttack(time, velocity); 158 | this.oscillator1.start(time); 159 | this.oscillator2.start(time); 160 | this.noise.start(time); 161 | this.LFO_1.restart(); 162 | this.LFO_2.restart(); 163 | this.isActive = true; 164 | 165 | if (this.AMP_EG.sustain === 0) { 166 | const computedAttack = this.toSeconds(this.AMP_EG.attack); 167 | const computedDecay = this.toSeconds(this.AMP_EG.decay); 168 | this.oscillator1.stop(time + computedAttack + computedDecay); 169 | this.oscillator2.stop(time + computedAttack + computedDecay); 170 | this.noise.stop(time + computedAttack + computedDecay); 171 | } 172 | } 173 | 174 | _triggerEnvelopeRelease(time) { 175 | this.AMP_EG.triggerRelease(time); 176 | this.EG_1.triggerRelease(time); 177 | this.oscillator1.stop(time + this.toSeconds(this.AMP_EG.release)); 178 | this.oscillator2.stop(time + this.toSeconds(this.AMP_EG.release)); 179 | this.noise.stop(time + this.toSeconds(this.AMP_EG.release)); 180 | this.LFO_1.restop(time + this.toSeconds(this.AMP_EG.release)); 181 | this.LFO_2.restop(time + this.toSeconds(this.AMP_EG.release)); 182 | } 183 | 184 | _setFree() { 185 | this.isActive = false; 186 | return this; 187 | } 188 | 189 | reset() { 190 | this._noteNumber = -1; 191 | return this; 192 | } 193 | 194 | dispose() { 195 | super.dispose(); 196 | this.modMatrix.dispose(); 197 | this.filterEGNode.dispose(); 198 | this.oscillator1.dispose(); 199 | this.oscillator2.dispose(); 200 | this.noise.dispose(); 201 | this.AMP_EG.dispose(); 202 | this.EG_1.dispose(); 203 | this.pan1.dispose(); 204 | this.pan2.dispose(); 205 | this.pan3.dispose(); 206 | this.gain1.dispose(); 207 | this.gain2.dispose(); 208 | this.gain3.dispose(); 209 | this.gain123Node.disconnect(); 210 | this.gain123Node.dispose(); 211 | this.pitch12Node.disconnect(); 212 | this.pitch12Node.dispose(); 213 | this.AMP.dispose(); 214 | this.filter.dispose(); 215 | this.LFO_1.dispose(); 216 | this.LFO_2.dispose(); 217 | return this; 218 | } 219 | 220 | setModMatrix(options) { 221 | this.modMatrix.set(options); 222 | } 223 | } 224 | 225 | export class VoiceManager extends PolySynth { 226 | constructor() { 227 | super(optionsFromArguments(VoiceManager.getDefaults(), arguments)); 228 | this.globalModMatrix = new ModMatrix(); 229 | /* --------------------------- */ 230 | // this.initGlobalMatrix(); 231 | this.initVoices(); 232 | } 233 | 234 | static getDefaults() { 235 | return Object.assign(PolySynth.getDefaults(), { 236 | options: {}, 237 | maxPolyphony: 8, 238 | voice: Voice 239 | }); 240 | } 241 | 242 | // initGlobalMatrix() { 243 | // this.globalModMatrix.sources[SOURCES.LFO_1_OUT] = this.LFO1; 244 | // this.globalModMatrix.destinations[DESTINATIONS.LFO_1_OUT] = this.LFO1.frequency; 245 | // } 246 | 247 | initVoices() { 248 | // create poly voices 249 | for (let i = 0; i < this.maxPolyphony; i++) { 250 | const voice = new this.voice( 251 | Object.assign(this.options, { 252 | context: this.context, 253 | onsilence: this._makeVoiceAvailable.bind(this) 254 | }) 255 | ); 256 | // set modulation matrix per voice 257 | 258 | if (i === 0) { 259 | // global params (MUST BE DONE before setting up mod matrix!) 260 | voice.modMatrix.initializeModMatrix(this.globalModMatrix, this); 261 | } 262 | // all matrices share a common core array of matrix rows 263 | voice.modMatrix.setMatrixCore(this.globalModMatrix.getMatrixCore()); 264 | voice.modMatrix.initializeModulators(); 265 | // global parameters 266 | 267 | voice.connect(this.output); 268 | this._voices.push(voice); 269 | } 270 | } 271 | 272 | _getNextAvailableVoice() { 273 | // skip expensive initialization of PolySynth `dummyVoice` property 274 | return new Gain(0); 275 | } 276 | 277 | /** 278 | * simplified version of PolySynth.set() \ 279 | * options are not sanitized, but just injected to every voice 280 | * @param {Object} options 281 | */ 282 | setVoiceValue(options) { 283 | this._voices.forEach(voice => voice.set(options)); 284 | } 285 | 286 | dispose() { 287 | super.dispose(); 288 | 289 | this.globalModMatrix.dispose(); 290 | return this; 291 | } 292 | 293 | _findFreeVoice() { 294 | let freeVoice = null; 295 | for (let i = 0; i < this.maxPolyphony; i++) { 296 | if (!this._voices[i].isActive) { 297 | freeVoice = this._voices[i]; 298 | break; 299 | } 300 | } 301 | return freeVoice; 302 | } 303 | 304 | _triggerAttack(notes, time, velocity) { 305 | notes.forEach(note => { 306 | const voice = this._findFreeVoice(); 307 | if (!voice) return; 308 | 309 | const midiNote = new MidiClass(this.context, note).toMidi(); 310 | if (voice) { 311 | voice.triggerAttack(note, time, velocity); 312 | voice._noteNumber = midiNote; 313 | } 314 | }); 315 | } 316 | 317 | _triggerRelease(notes, time) { 318 | notes.forEach(note => { 319 | const midiNote = new MidiClass(this.context, note).toMidi(); 320 | for (let i = 0; i < this.maxPolyphony; i++) { 321 | let voice = this._voices[i]; 322 | if (voice.isActive && voice._noteNumber === midiNote) { 323 | // trigger release on that note 324 | voice.triggerRelease(time); 325 | } 326 | } 327 | }); 328 | } 329 | 330 | _makeVoiceAvailable(voice) { 331 | voice.isActive = false; 332 | } 333 | 334 | /** 335 | * simplified version of PolySynth.set() \ 336 | * options are not sanitized, but just injected to every voice 337 | * @param {Object} options 338 | */ 339 | setVoiceModMatrix(options) { 340 | this._voices.forEach(voice => voice.setModMatrix(options)); 341 | } 342 | 343 | async *getControls() { 344 | const controls = VoiceControls(); 345 | var i; 346 | for (i = controls.length; i--; ) { 347 | yield controls[i]; 348 | } 349 | } 350 | } 351 | 352 | function VoiceControls() { 353 | const controls = []; 354 | let control; 355 | // ------------- osc -------------- 356 | for (let i = 1; i <= 2; i++) { 357 | control = { 358 | options: { 359 | type: CONTROL_TYPES.DISPLAYPICKER, 360 | id: CONTROLLERS[`OSC_${i}_VOICE`], 361 | min: 0, 362 | max: 4, 363 | step: 1, 364 | snap: true, 365 | label: "voices", 366 | value: 1, 367 | default: 1, 368 | digits: 0 369 | }, 370 | command: { 371 | type: CMD.OscillatorCommand, 372 | args: [i, "count"] 373 | } 374 | }; 375 | controls.push(control); 376 | control = { 377 | options: { 378 | type: CONTROL_TYPES.KNOB, 379 | id: CONTROLLERS[`OSC_${i}_SPRD`], 380 | min: 0, 381 | max: 100, 382 | step: 1, 383 | label: "detune", 384 | value: 0, 385 | digits: 0 386 | }, 387 | command: { 388 | type: CMD.OscillatorCommand, 389 | args: [i, "spread"] 390 | } 391 | }; 392 | controls.push(control); 393 | control = { 394 | options: { 395 | type: CONTROL_TYPES.DISPLAYPICKER, 396 | id: CONTROLLERS[`OSC_${i}_OCT`], 397 | min: -3, 398 | max: 3, 399 | step: 1, 400 | bipolar: true, 401 | snap: true, 402 | label: "octave", 403 | value: 0, 404 | digits: 0 405 | }, 406 | command: { 407 | type: CMD.OscillatorCommand, 408 | args: [i, "octave"] 409 | } 410 | }; 411 | controls.push(control); 412 | control = { 413 | options: { 414 | type: CONTROL_TYPES.DISPLAYPICKER, 415 | id: CONTROLLERS[`OSC_${i}_SEMI`], 416 | min: -12, 417 | max: 12, 418 | bipolar: true, 419 | step: 1, 420 | snap: true, 421 | label: "semi", 422 | digits: 0 423 | }, 424 | command: { 425 | type: CMD.OscillatorCommand, 426 | args: [i, "semi"] 427 | } 428 | }; 429 | controls.push(control); 430 | control = { 431 | options: { 432 | type: CONTROL_TYPES.KNOB, 433 | id: CONTROLLERS[`OSC_${i}_FINE`], 434 | min: -100, 435 | max: 100, 436 | bipolar: true, 437 | step: 1, 438 | snap: true, 439 | label: "fine", 440 | digits: 0 441 | }, 442 | command: { 443 | type: CMD.OscillatorCommand, 444 | args: [i, "fine"] 445 | } 446 | }; 447 | controls.push(control); 448 | //------------------- 449 | control = { 450 | options: { 451 | type: CONTROL_TYPES.KNOB, 452 | id: CONTROLLERS[`OSC_${i}_PHASE`], 453 | min: 0, 454 | max: 360, 455 | step: 1, 456 | label: "phase", 457 | digits: 0 458 | }, 459 | command: { 460 | type: CMD.OscillatorCommand, 461 | args: [i, "phase"] 462 | } 463 | }; 464 | controls.push(control); 465 | //------------------- 466 | control = { 467 | options: { 468 | type: CONTROL_TYPES.WAVEPICKER, 469 | id: CONTROLLERS[`OSC_${i}_TP`], 470 | step: 1, 471 | min: 0, 472 | snap: true, 473 | max: OSCWaves.length - 1, 474 | label: "wave", 475 | digits: 0 476 | }, 477 | command: { 478 | type: CMD.OscTypeCommand, 479 | args: [i] 480 | } 481 | }; 482 | controls.push(control); 483 | //------------------- 484 | control = { 485 | options: { 486 | type: CONTROL_TYPES.TOGGLE, 487 | id: CONTROLLERS[`OSC_${i}_RETR`], 488 | label: "retrig", 489 | digits: 0 490 | }, 491 | command: { 492 | type: CMD.OscillatorCommand, 493 | args: [i, "retrigger"] 494 | } 495 | }; 496 | controls.push(control); 497 | } 498 | // ------------ /osc -------------- 499 | // ------------ mixer ------------- 500 | for (let i = 1; i <= 3; i++) { 501 | //------------------- 502 | control = { 503 | options: { 504 | type: CONTROL_TYPES.FADER, 505 | id: CONTROLLERS[`OSC_${i}_VOL`], 506 | label: i == 3 ? "noise" : "osc" + i, 507 | min: 0, 508 | max: 1 509 | }, 510 | command: { 511 | type: CMD.VolumeCommand, 512 | args: [i] 513 | } 514 | }; 515 | controls.push(control); 516 | //------------------- 517 | control = { 518 | options: { 519 | type: CONTROL_TYPES.KNOB, 520 | id: CONTROLLERS[`OSC_${i}_PAN`], 521 | bipolar: true, 522 | min: -1, 523 | max: 1, 524 | width: "2.3em" 525 | }, 526 | command: { 527 | type: CMD.PanCommand, 528 | args: [i] 529 | } 530 | }; 531 | controls.push(control); 532 | } 533 | // ------------ /mixer ------------ 534 | // ------------ filter ------------ 535 | //---------------------- 536 | control = { 537 | options: { 538 | type: CONTROL_TYPES.KNOB, 539 | id: CONTROLLERS.FILTER_CUT, 540 | min: 20, 541 | max: 20000, 542 | label: "cutoff", 543 | default: 10000, 544 | exp: 3, 545 | units: "Hz" 546 | }, 547 | command: { 548 | type: CMD.FilterCommand, 549 | args: ["cutoff"] 550 | } 551 | }; 552 | controls.push(control); 553 | //---------------------- 554 | control = { 555 | options: { 556 | type: CONTROL_TYPES.KNOB, 557 | id: CONTROLLERS.FILTER_Q, 558 | min: 0, 559 | max: 10, 560 | label: "reso" 561 | }, 562 | command: { 563 | type: CMD.FilterCommand, 564 | args: ["Q"] 565 | } 566 | }; 567 | controls.push(control); 568 | //---------------------- 569 | control = { 570 | options: { 571 | type: CONTROL_TYPES.DISPLAYPICKER, 572 | id: CONTROLLERS.FILTER_TP, 573 | step: 1, 574 | snap: true, 575 | size: 4, 576 | max: FILTERTypes.length - 1, 577 | items: ["LP12", "LP24", "BP12", "BP24", "HP12", "HP24", "NONE"], 578 | label: "type", 579 | digits: 0 580 | }, 581 | command: { 582 | type: CMD.FilterTypeCommand, 583 | args: [] 584 | } 585 | }; 586 | controls.push(control); 587 | //---------------------- 588 | control = { 589 | options: { 590 | type: CONTROL_TYPES.KNOB, 591 | id: CONTROLLERS.FILTER_EG, 592 | min: 0, 593 | max: 1, 594 | label: "env" 595 | }, 596 | command: { 597 | type: CMD.VoiceCommand, 598 | args: ["filterEG"] 599 | } 600 | }; 601 | controls.push(control); 602 | // ------------ /filter ------------ 603 | // ------------ AMP EG ------------- 604 | //---------------------- 605 | control = { 606 | options: { 607 | type: CONTROL_TYPES.KNOB, 608 | id: CONTROLLERS.AMP_EG_ATT, 609 | min: 0.001, 610 | max: 10, 611 | default: 0.005, 612 | label: "attack", 613 | exp: 3, 614 | units: "sec", 615 | digits: 3 616 | }, 617 | command: { 618 | type: CMD.AMPEnvCommand, 619 | args: ["attack"] 620 | } 621 | }; 622 | controls.push(control); 623 | //---------------------- 624 | control = { 625 | options: { 626 | type: CONTROL_TYPES.KNOB, 627 | id: CONTROLLERS.AMP_EG_DEC, 628 | min: 0.001, 629 | max: 10, 630 | default: 0.1, 631 | label: "decay", 632 | exp: 3, 633 | units: "sec", 634 | digits: 3 635 | }, 636 | command: { 637 | type: CMD.AMPEnvCommand, 638 | args: ["decay"] 639 | } 640 | }; 641 | controls.push(control); 642 | //---------------------- 643 | control = { 644 | options: { 645 | type: CONTROL_TYPES.KNOB, 646 | id: CONTROLLERS.AMP_EG_SUS, 647 | min: 0, 648 | max: 1, 649 | default: 1, 650 | label: "sustain" 651 | }, 652 | command: { 653 | type: CMD.AMPEnvCommand, 654 | args: ["sustain"] 655 | } 656 | }; 657 | controls.push(control); 658 | //---------------------- 659 | control = { 660 | options: { 661 | type: CONTROL_TYPES.KNOB, 662 | id: CONTROLLERS.AMP_EG_REL, 663 | min: 0.001, 664 | max: 10, 665 | default: 0.05, 666 | label: "release", 667 | exp: 3, 668 | units: "sec", 669 | digits: 3 670 | }, 671 | command: { 672 | type: CMD.AMPEnvCommand, 673 | args: ["release"] 674 | } 675 | }; 676 | controls.push(control); 677 | // ------------ /AMP EG ------------ 678 | // ------------ FILTER EG ---------- 679 | control = { 680 | options: { 681 | type: CONTROL_TYPES.KNOB, 682 | id: CONTROLLERS.EG_1_ATT, 683 | min: 0.001, 684 | max: 10, 685 | default: 0.005, 686 | label: "attack", 687 | exp: 3, 688 | units: "sec", 689 | digits: 3 690 | }, 691 | command: { 692 | type: CMD.FilterEnvCommand, 693 | args: ["attack"] 694 | } 695 | }; 696 | controls.push(control); 697 | //---------------------- 698 | control = { 699 | options: { 700 | type: CONTROL_TYPES.KNOB, 701 | id: CONTROLLERS.EG_1_DEC, 702 | min: 0.001, 703 | max: 10, 704 | default: 0.1, 705 | label: "decay", 706 | exp: 3, 707 | units: "sec", 708 | digits: 3 709 | }, 710 | command: { 711 | type: CMD.FilterEnvCommand, 712 | args: ["decay"] 713 | } 714 | }; 715 | controls.push(control); 716 | //---------------------- 717 | control = { 718 | options: { 719 | type: CONTROL_TYPES.KNOB, 720 | id: CONTROLLERS.EG_1_SUS, 721 | min: 0, 722 | max: 1, 723 | default: 1, 724 | label: "sustain" 725 | }, 726 | command: { 727 | type: CMD.FilterEnvCommand, 728 | args: ["sustain"] 729 | } 730 | }; 731 | controls.push(control); 732 | //---------------------- 733 | control = { 734 | options: { 735 | type: CONTROL_TYPES.KNOB, 736 | id: CONTROLLERS.EG_1_REL, 737 | min: 0.001, 738 | max: 10, 739 | default: 0.05, 740 | label: "release", 741 | exp: 3, 742 | units: "sec", 743 | digits: 3 744 | }, 745 | command: { 746 | type: CMD.FilterEnvCommand, 747 | args: ["release"] 748 | } 749 | }; 750 | controls.push(control); 751 | // ----------- /FILTER EG ---------- 752 | // ------------ LFOs -------------- 753 | for (let i = 0; i < 2; i++) { 754 | control = { 755 | options: { 756 | type: CONTROL_TYPES.WAVEPICKER, 757 | id: CONTROLLERS[`LFO_${i + 1}_TP`], 758 | step: 1, 759 | min: 0, 760 | snap: true, 761 | max: LFOWaves.length - 1, 762 | label: "wave", 763 | digits: 0 764 | }, 765 | command: { 766 | type: CMD.LFOTypeCommand, 767 | args: [i] 768 | } 769 | }; 770 | controls.push(control); 771 | //---------------------- 772 | control = { 773 | options: { 774 | type: CONTROL_TYPES.KNOB, 775 | id: CONTROLLERS[`LFO_${i + 1}_FC`], 776 | min: 0.2, 777 | max: 10, 778 | default: 5.1, 779 | label: "freq", 780 | units: "Hz" 781 | }, 782 | command: { 783 | type: CMD.LFOCommand, 784 | args: [i, "frequency"] 785 | } 786 | }; 787 | controls.push(control); 788 | //---------------------- 789 | control = { 790 | options: { 791 | type: CONTROL_TYPES.KNOB, 792 | id: CONTROLLERS[`LFO_${i + 1}_GAIN`], 793 | min: 0, 794 | max: 1, 795 | default: 1, 796 | label: "gain" 797 | }, 798 | command: { 799 | type: CMD.LFOCommand, 800 | args: [i, "amplitude"] 801 | } 802 | }; 803 | controls.push(control); 804 | //---------------------- 805 | control = { 806 | options: { 807 | type: CONTROL_TYPES.KNOB, 808 | id: CONTROLLERS[`LFO_${i + 1}_PHASE`], 809 | min: 0, 810 | max: 360, 811 | step: 1, 812 | label: "phase", 813 | digits: 0 814 | }, 815 | command: { 816 | type: CMD.LFOCommand, 817 | args: [i, "phase"] 818 | } 819 | }; 820 | controls.push(control); 821 | //---------------------- 822 | control = { 823 | options: { 824 | type: CONTROL_TYPES.TOGGLE, 825 | id: CONTROLLERS[`LFO_${i + 1}_RETR`], 826 | label: "retrig", 827 | digits: 0 828 | }, 829 | command: { 830 | type: CMD.LFOCommand, 831 | args: [i, "retrigger"] 832 | } 833 | }; 834 | controls.push(control); 835 | } 836 | // ------------ /LFOs ------------- 837 | // ------------ Matrix ------------- 838 | for (let i = 0; i < 5; i++) { 839 | control = { 840 | options: { 841 | id: CONTROLLERS[`M_ROW_${i + 1}`], 842 | value: [0, 0, 0] 843 | }, 844 | command: { 845 | type: CMD.ModMatrixCommand, 846 | args: [i] 847 | } 848 | }; 849 | controls.push(control); 850 | } 851 | // ------------ /Matrix ------------- 852 | return controls; 853 | } 854 | -------------------------------------------------------------------------------- /src/Synth/commands/index.js: -------------------------------------------------------------------------------- 1 | import * as globals from "@/Synth/globals"; 2 | import { throttle } from "@/utils"; 3 | 4 | function getOscillator(osc) { 5 | let result; 6 | switch (osc) { 7 | case 1: 8 | result = "oscillator1"; 9 | break; 10 | case 2: 11 | result = "oscillator2"; 12 | break; 13 | default: 14 | break; 15 | } 16 | return result; 17 | } 18 | 19 | export class Command { 20 | constructor(context) { 21 | this.context = context; 22 | this.execute = throttle(this.executeCommand.bind(this), 100); 23 | } 24 | executeCommand(value) {} 25 | } 26 | 27 | export class OscillatorCommand extends Command { 28 | constructor(context, osc, param) { 29 | super(context); 30 | this.osc = osc; 31 | this.param = param; 32 | } 33 | executeCommand(value) { 34 | const osc = getOscillator(this.osc); 35 | if (!osc || !this.param) return; 36 | this.context.setVoiceValue({ [osc]: { [this.param]: value } }); 37 | } 38 | } 39 | 40 | export class OscTypeCommand extends Command { 41 | constructor(context, osc) { 42 | super(context); 43 | this.osc = osc; 44 | } 45 | executeCommand(value) { 46 | const osc = getOscillator(this.osc); 47 | if (!osc) return; 48 | const type = globals.OSCWaves[value] || globals.OSCWaves[0]; 49 | this.context.setVoiceValue({ [osc]: { type } }); 50 | } 51 | } 52 | 53 | export class PanCommand extends Command { 54 | constructor(context, idx, param) { 55 | super(context); 56 | this.idx = idx; 57 | this.param = param; 58 | } 59 | executeCommand(value) { 60 | const panParam = "pan" + this.idx; 61 | this.context.setVoiceValue({ [panParam]: { pan: value } }); 62 | } 63 | } 64 | export class VolumeCommand extends Command { 65 | constructor(context, idx) { 66 | super(context); 67 | this.idx = idx; 68 | } 69 | executeCommand(value) { 70 | const gainParam = "gain" + this.idx; 71 | this.context.setVoiceValue({ [gainParam]: { gain: value } }); 72 | } 73 | } 74 | 75 | export class VoiceCommand extends Command { 76 | constructor(context, destination) { 77 | super(context); 78 | this.destination = destination; // string 79 | } 80 | executeCommand(value) { 81 | this.context.setVoiceValue({ [this.destination]: value }); 82 | } 83 | } 84 | 85 | /* ---------- Filter ---------- */ 86 | export class FilterFcCommand extends Command { 87 | constructor(context) { 88 | super(context); 89 | } 90 | executeCommand(value) { 91 | this.context.setVoiceValue({ filter: { cutoff: value } }); 92 | } 93 | } 94 | 95 | export class FilterTypeCommand extends Command { 96 | constructor(context) { 97 | super(context); 98 | } 99 | executeCommand(value) { 100 | const type = globals.FILTERTypes[value] || globals.FILTERTypes[0]; 101 | this.context.setVoiceValue({ filter: { ...type } }); 102 | } 103 | } 104 | 105 | export class FilterCommand extends Command { 106 | constructor(context, param) { 107 | super(context); 108 | this.param = param; 109 | } 110 | executeCommand(value) { 111 | this.context.setVoiceValue({ filter: { [this.param]: value } }); 112 | } 113 | } 114 | 115 | export class AMPEnvCommand extends Command { 116 | constructor(context, param) { 117 | super(context); 118 | this.param = param; 119 | } 120 | executeCommand(value) { 121 | this.context.setVoiceValue({ AMP_EG: { [this.param]: value } }); 122 | } 123 | } 124 | 125 | export class FilterEnvCommand extends Command { 126 | constructor(context, param) { 127 | super(context); 128 | this.param = param; 129 | } 130 | executeCommand(value) { 131 | this.context.setVoiceValue({ EG_1: { [this.param]: value } }); 132 | } 133 | } 134 | 135 | export class LFOTypeCommand extends Command { 136 | constructor(context, idx) { 137 | super(context); 138 | this.idx = idx; 139 | } 140 | executeCommand(value) { 141 | const lfo = this.idx === 1 ? "LFO_2" : "LFO_1"; 142 | const type = globals.LFOWaves[value] || globals.LFOWaves[0]; 143 | this.context.setVoiceValue({ [lfo]: { type } }); 144 | } 145 | } 146 | export class LFOCommand extends Command { 147 | constructor(context, idx, param) { 148 | super(context); 149 | this.idx = idx; 150 | this.param = param; 151 | } 152 | executeCommand(value) { 153 | const lfo = this.idx === 1 ? "LFO_2" : "LFO_1"; 154 | this.context.setVoiceValue({ [lfo]: { [this.param]: value } }); 155 | } 156 | } 157 | 158 | export class MatrixCommand extends Command { 159 | constructor(context) { 160 | super(context); 161 | } 162 | 163 | executeCommand(value) { 164 | this.context.setVoiceModMatrix(value); 165 | } 166 | } 167 | 168 | export class ModMatrixCommand extends Command { 169 | constructor(context, row) { 170 | super(context); 171 | this.row = row; 172 | } 173 | 174 | executeCommand(value) { 175 | this.context.setVoiceModMatrix({ id: this.row, value }); 176 | } 177 | } 178 | 179 | export class FXCommand extends Command { 180 | constructor(context, effect, param) { 181 | super(context); 182 | this.effect = effect; 183 | this.param = param; 184 | } 185 | // effect options 186 | executeCommand(value) { 187 | this.context.setFX(this.effect, { effect: { [this.param]: value } }); 188 | } 189 | } 190 | export class FXBypassCommand extends Command { 191 | constructor(context, effect, param) { 192 | super(context); 193 | this.effect = effect; 194 | this.param = param; 195 | } 196 | // bypass is effect wrapper class property 197 | executeCommand(value) { 198 | this.context.setFX(this.effect, { on: value }); 199 | } 200 | } 201 | 202 | export class BaseCommand extends Command { 203 | constructor(context, param) { 204 | super(context); 205 | this.param = param; // string 206 | } 207 | executeCommand(value) { 208 | this.context.set({ [this.param]: value }); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/Synth/control/index.js: -------------------------------------------------------------------------------- 1 | import { Command } from "@/Synth/commands"; 2 | 3 | export class Control { 4 | constructor(options = {}) { 5 | Object.defineProperties(this, { 6 | _id: { writable: true, enumerable: false }, // enumerable:false = do not return in destructured object 7 | id: { 8 | get: function() { 9 | return this._id; 10 | }, 11 | set: function(val) { 12 | if (this._id !== undefined) { 13 | throw new Error("Can not change id!"); 14 | } 15 | // check undefined/null || empty string, allow 0 as id 16 | if (val == undefined || val === "") { 17 | throw new Error("Invalid id value!"); 18 | } 19 | this._id = val; 20 | }, 21 | enumerable: true 22 | }, 23 | _command: { writable: true, enumerable: false, configurable: false } // configurable:false = non-reactive 24 | }); 25 | this.type = undefined; 26 | this._value = options.value || 0; // must be here to remain reactive getter/setter 27 | Object.assign(this, options, this.constructor.getDefaults()); 28 | if (this.id === undefined) throw new Error("Missing `id` parameter for", this.label); 29 | } 30 | 31 | get value() { 32 | return this._value; 33 | } 34 | 35 | set value(newValue) { 36 | this._value = newValue; 37 | this._command && this._command.execute(newValue); 38 | } 39 | 40 | setCommand(command) { 41 | if (command instanceof Command) { 42 | this._command = command; 43 | } 44 | } 45 | 46 | static getDefaults() { 47 | return {}; 48 | } 49 | } 50 | 51 | export class ControlManager { 52 | constructor() { 53 | this._controls = new Map(); 54 | } 55 | 56 | addControl(control) { 57 | this._controls.set(control.id, control); 58 | } 59 | 60 | async createControls(context) { 61 | const controls = context.getControls(); 62 | if (!controls) return; 63 | let c, cmd, ctrl; 64 | 65 | for await (c of controls) { 66 | ctrl = new Control(c.options); 67 | cmd = new c.command.type(context, ...c.command.args); 68 | ctrl.setCommand(cmd); 69 | this.addControl(ctrl); 70 | } 71 | return true; 72 | } 73 | 74 | _hasControls() { 75 | return Object.values(this._controls).length === this._controls.length; 76 | } 77 | 78 | getPreset() { 79 | const res = {}; 80 | this._controls.forEach((v, k) => { 81 | res[k] = v.value; 82 | }); 83 | return res; 84 | } 85 | 86 | usePreset(preset) { 87 | const values = preset.values; 88 | this._controls.forEach((v, k) => { 89 | if (values[k] == undefined) return; 90 | v.value = values[k]; 91 | }); 92 | } 93 | 94 | getControl(key) { 95 | return this._controls.get(key); 96 | } 97 | 98 | dispose() { 99 | this._controls.clear(); 100 | return this; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Synth/globals.js: -------------------------------------------------------------------------------- 1 | export const OSCWaves = ["sine", "sawtooth", "triangle", "square"]; 2 | export const LFOWaves = ["sine", "sawtooth", "triangle", "square"]; 3 | export const FILTERTypes = [ 4 | { type: "lowpass", rolloff: -12 }, 5 | { type: "lowpass", rolloff: -24 }, 6 | { type: "bandpass", rolloff: -12 }, 7 | { type: "bandpass", rolloff: -24 }, 8 | { type: "highpass", rolloff: -12 }, 9 | { type: "highpass", rolloff: -24 }, 10 | { type: "allpass" } 11 | ]; 12 | -------------------------------------------------------------------------------- /src/Synth/index.js: -------------------------------------------------------------------------------- 1 | import { Gain, Frequency, optionsFromArguments, Meter } from "tone"; 2 | import { VoiceManager } from "./Voice"; 3 | import { EffectModule } from "./Effects"; 4 | import { Instrument } from "tone/build/esm/instrument/Instrument"; 5 | import { ControlManager } from "@/Synth/control"; 6 | import { BaseCommand } from "@/Synth/commands"; 7 | import { CONTROLLERS, CONTROL_TYPES } from "@/Synth/synthfunctions"; 8 | import { PresetManager } from "./PresetManager"; 9 | let presets; 10 | try { 11 | presets = require("./presets").default; 12 | } catch (e) { 13 | console.warn("presets file not found."); 14 | } 15 | 16 | function midiToNote(midi) { 17 | return Frequency(midi, "midi").toFrequency(); 18 | } 19 | 20 | export default class Synthesizer extends Instrument { 21 | constructor() { 22 | super(optionsFromArguments(Synthesizer.getDefaults(), arguments)); 23 | // const options = optionsFromArguments(Synthesizer.getDefaults(), arguments); 24 | this.ready = false; 25 | this.voiceManager = new VoiceManager(); 26 | this.effects = new EffectModule(); 27 | this._masterGain = new Gain(); 28 | this.controlManager = new ControlManager(); 29 | this.meter = new Meter({ channels: 2, normalRange: true, smoothing: 0.99 }); 30 | 31 | this.voiceManager.chain(this.effects, this._masterGain, this.meter, this.output); 32 | this.presetManager = new PresetManager(this, presets); 33 | } 34 | 35 | init() { 36 | var p = this.controlManager.createControls(this.voiceManager), 37 | p1 = this.controlManager.createControls(this.effects), 38 | p2 = this.controlManager.createControls(this); 39 | Promise.all([p, p1, p2]).then(() => { 40 | this.ready = true; 41 | this.setPreset(this.presetManager.currentPreset); 42 | return true; 43 | }); 44 | } 45 | 46 | triggerAttack(note, time, velocity) { 47 | this.voiceManager.triggerAttack(midiToNote(note), time, velocity); 48 | } 49 | 50 | triggerRelease(note, time) { 51 | this.voiceManager.triggerRelease(midiToNote(note), time); 52 | } 53 | 54 | dumpPreset() { 55 | this.voiceManager.releaseAll(); 56 | const preset = this.controlManager.getPreset(); 57 | console.log("preset", preset); 58 | } 59 | 60 | setPreset(preset) { 61 | this.voiceManager.releaseAll(); 62 | this.controlManager.usePreset(preset); 63 | } 64 | 65 | getControl(id) { 66 | return this.controlManager.getControl(id); 67 | } 68 | 69 | get getControlMatrix() { 70 | const arr = []; 71 | for (let i = 1; i <= 5; i++) { 72 | arr.push(this.getControl(CONTROLLERS[`M_ROW_${i}`])); 73 | } 74 | return arr; 75 | } 76 | 77 | panic() { 78 | this.voiceManager.releaseAll(); 79 | this.dispose(); 80 | } 81 | 82 | get masterGain() { 83 | return this._masterGain.gain.value; 84 | } 85 | 86 | set masterGain(value) { 87 | this._masterGain.gain.value = value; 88 | } 89 | 90 | dispose() { 91 | super.dispose(); 92 | this.controlManager.dispose(); 93 | this.voiceManager.dispose(); 94 | this.effects.dispose(); 95 | this._masterGain.dispose(); 96 | this.meter.dispose(); 97 | return this; 98 | } 99 | 100 | async *getControls() { 101 | const controls = MasterControls(); 102 | var i; 103 | for (i = controls.length; i--; ) { 104 | yield controls[i]; 105 | } 106 | } 107 | } 108 | 109 | function MasterControls() { 110 | const controls = []; 111 | let control; 112 | // MASTER ------------------ 113 | control = { 114 | options: { 115 | type: CONTROL_TYPES.KNOB, 116 | id: CONTROLLERS.MASTER_GAIN, 117 | min: 0, 118 | max: 1, 119 | default: 0.5, 120 | label: "gain", 121 | width: "4em", 122 | color: "#ff0000" 123 | }, 124 | command: { 125 | type: BaseCommand, 126 | args: ["masterGain"] 127 | } 128 | }; 129 | controls.push(control); 130 | return controls; 131 | } 132 | -------------------------------------------------------------------------------- /src/Synth/nodes/Distortion.js: -------------------------------------------------------------------------------- 1 | import { Distortion, optionsFromArguments } from "tone"; 2 | import { DISTORTION_ALGORITHMS } from "@/Synth/synthfunctions"; 3 | 4 | export default class DistortionModule extends Distortion { 5 | constructor() { 6 | super(optionsFromArguments(DistortionModule.getDefaults(), arguments)); 7 | const options = optionsFromArguments(DistortionModule.getDefaults(), arguments); 8 | this._algorithmFn = null; 9 | this._type = null; 10 | this.type = options.type; 11 | } 12 | 13 | static getDefaults() { 14 | return Object.assign(Distortion.getDefaults(), { 15 | type: 0 16 | }); 17 | } 18 | 19 | static getAlgorithm(idx) { 20 | return DISTORTION_ALGORITHMS[idx]; 21 | } 22 | 23 | set type(value) { 24 | if (value === this._type) return; 25 | const fn = DistortionModule.getAlgorithm(value); 26 | if (!fn) return; 27 | this._type = value; 28 | this._algorithmFn = fn; 29 | this._setDistortionAlgorithm(); 30 | } 31 | 32 | get type() { 33 | return this._type; 34 | } 35 | 36 | _setDistortionAlgorithm() { 37 | const fn = this._algorithmFn(this._distortion); 38 | this._shaper.setMap(fn); 39 | } 40 | 41 | /** 42 | * The amount of distortion. Nominal range is between 0 and 1. 43 | */ 44 | get distortion() { 45 | return this._distortion; 46 | } 47 | 48 | set distortion(amount) { 49 | if (this.type == undefined) return; 50 | this._distortion = amount; 51 | this._setDistortionAlgorithm(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Synth/nodes/ENVELOPE.js: -------------------------------------------------------------------------------- 1 | import { Envelope } from "tone"; 2 | 3 | /* 4 | //////////////////////////// 5 | eased decay curve 6 | //////////////////////////// 7 | */ 8 | // 1. create linear values array [0,1] (increasing values) 9 | function linearCurve() { 10 | //ToneJS standard value for envelope curve 11 | const curveLen = 128; 12 | const curve = new Array(curveLen); 13 | for (var i = curveLen; i--; ) { 14 | curve[i] = i / (curveLen - 1); 15 | } 16 | return curve; 17 | } 18 | 19 | // 2. invert curve to get decreasing values [1,0] 20 | function invertCurve(curve) { 21 | const out = new Array(curve.length); 22 | for (let j = 0; j < curve.length; j++) { 23 | out[j] = 1 - curve[j]; 24 | } 25 | return out; 26 | } 27 | 28 | // 3. ease linear curve with exponent function [1,0] 29 | function exponentCurve(curve, exp) { 30 | const pow = Math.pow; 31 | return curve.map(i => pow(i, exp)); 32 | } 33 | // 4. scale curve [1,0] to range [1, sustain] - `decay` curve decrease signal in time 34 | function easedCurve(curve, sustain) { 35 | return curve.map(i => i * (1 - sustain) + sustain); 36 | } 37 | 38 | // 5. pre-compute values with fixed exponent to avoid expensive Math.pow() calculation every update 39 | const decayCurve = exponentCurve(invertCurve(linearCurve()), 3); 40 | 41 | const decayExpCurve = easedCurve.bind(null, decayCurve); 42 | 43 | export default class EG extends Envelope { 44 | triggerAttack(time, velocity = 1) { 45 | time = this.toSeconds(time); 46 | const originalAttack = this.toSeconds(this.attack); 47 | let attack = originalAttack; 48 | const decay = this.toSeconds(this.decay); 49 | // check if it's not a complete attack 50 | const currentValue = this.getValueAtTime(time); 51 | if (currentValue > 0) { 52 | // subtract the current value from the attack time 53 | const attackRate = 1 / attack; 54 | const remainingDistance = 1 - currentValue; 55 | // the attack is now the remaining time 56 | attack = remainingDistance / attackRate; 57 | } 58 | // attack 59 | if (attack < this.sampleTime) { 60 | this._sig.cancelScheduledValues(time); 61 | // case where the attack time is 0 should set instantly 62 | this._sig.setValueAtTime(velocity, time); 63 | } else if (this._attackCurve === "linear") { 64 | this._sig.linearRampTo(velocity, attack, time); 65 | } else if (this._attackCurve === "exponential") { 66 | this._sig.targetRampTo(velocity, attack, time); 67 | } else { 68 | this._sig.cancelAndHoldAtTime(time); 69 | let curve = this._attackCurve; 70 | // find the starting position in the curve 71 | for (let i = 1; i < curve.length; i++) { 72 | // the starting index is between the two values 73 | if (curve[i - 1] <= currentValue && currentValue <= curve[i]) { 74 | curve = this._attackCurve.slice(i); 75 | // the first index is the current value 76 | curve[0] = currentValue; 77 | break; 78 | } 79 | } 80 | this._sig.setValueCurveAtTime(curve, time, attack, velocity); 81 | } 82 | // decay 83 | if (decay && this.sustain < 1) { 84 | const decayValue = velocity * this.sustain; 85 | const decayStart = time + attack; 86 | if (this._decayCurve === "linear") { 87 | this._sig.linearRampToValueAtTime(decayValue, decay + decayStart); 88 | } else { 89 | // use custom exponential curve 90 | const decCurve = decayExpCurve(decayValue); 91 | this._sig.setValueCurveAtTime(decCurve, decayStart, decay, velocity); 92 | 93 | // handle sustain phase 94 | this._sig.cancelAndHoldAtTime(decayStart + decay * 0.99); // create some room to connect decay stage with sustain without artifacts 95 | this._sig.linearRampToValueAtTime(decayValue, decayStart + decay); 96 | } 97 | } 98 | return this; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Synth/nodes/FILTER.js: -------------------------------------------------------------------------------- 1 | import { optionsFromArguments, Filter, Signal, Multiply, WaveShaper, DCMeter } from "tone"; 2 | import { MODULATION } from "@/Synth/nodes"; 3 | export default class MyFilter extends Filter { 4 | constructor() { 5 | super(optionsFromArguments(MyFilter.getDefaults(), arguments)); 6 | const options = optionsFromArguments(MyFilter.getDefaults(), arguments); 7 | this._minFreq = options.minFreq; 8 | this._maxFreq = options.maxFreq; 9 | 10 | this.cutoffNode = new MODULATION({ 11 | exp: 3, 12 | units: "frequency", 13 | value: options.frequency, 14 | modRange: options.modFreqRange 15 | }); 16 | this.cutoffNode.output.connect(this.frequency); // works only this way 17 | } 18 | 19 | get cutoff() { 20 | return this.cutoffNode._signal.value; 21 | } 22 | 23 | set cutoff(value) { 24 | this.cutoffNode._signal.value = value; 25 | } 26 | 27 | static getDefaults() { 28 | return Object.assign(Filter.getDefaults(), { 29 | modFreqRange: 15000, 30 | minFreq: 20, 31 | maxFreq: 20000, 32 | frequency: 50 33 | }); 34 | } 35 | 36 | dispose() { 37 | this.frequency.dispose(); 38 | this.Q.dispose(); 39 | this.detune.dispose(); 40 | this.gain.dispose(); 41 | this.cutoffNode.disconnect(); 42 | this.cutoffNode.dispose(); 43 | // FIXME: writable() method in super.dispose() removes own properties, causing error 44 | try { 45 | super.dispose(); 46 | } catch (err) { 47 | console.debug(err.message); 48 | } finally { 49 | return this; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Synth/nodes/FX.js: -------------------------------------------------------------------------------- 1 | import { optionsFromArguments, Gain, ToneAudioNode } from "tone"; 2 | 3 | export default class FX extends ToneAudioNode { 4 | constructor() { 5 | super(optionsFromArguments(FX.getDefaults(), arguments)); 6 | const options = optionsFromArguments(FX.getDefaults(), arguments); 7 | this.effect = new options.effect({ ...options.options, context: this.context }); 8 | this._on = options.on; 9 | this._lastOn = options.on; 10 | this.input = new Gain(); 11 | this.output = new Gain(); 12 | 13 | this.effect.connect(this.output); 14 | this.activate(options.on); // turn on/off effect 15 | } 16 | 17 | static getDefaults() { 18 | return Object.assign(ToneAudioNode.getDefaults(), { 19 | on: 0 20 | }); 21 | } 22 | 23 | get on() { 24 | return this._on; 25 | } 26 | 27 | set on(val) { 28 | if (this._lastOn === val) return; 29 | this._on = this._lastOn = Boolean(val); 30 | this.activate(this._on); 31 | } 32 | 33 | activate(doActivate) { 34 | this.input.disconnect(); 35 | if (doActivate) { 36 | this.input.connect(this.effect); 37 | // some effects (ie chorus) need to start some properties (e.g. LFO) manually 38 | if (typeof this.effect.start === "function") { 39 | this.effect.start(); 40 | } 41 | } else { 42 | if (typeof this.effect.stop === "function") { 43 | this.effect.stop(); 44 | } 45 | this.input.connect(this.output); 46 | } 47 | } 48 | 49 | toggleOn() { 50 | this.on = !this._on; 51 | } 52 | 53 | dispose() { 54 | super.dispose(); 55 | this.effect.disconnect(); 56 | this.effect.dispose(); 57 | return this; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Synth/nodes/LFO.js: -------------------------------------------------------------------------------- 1 | import { optionsFromArguments, LFO as ToneLFO, Add, now } from "tone"; 2 | 3 | //==================== 4 | export default class LFO extends ToneLFO { 5 | constructor() { 6 | const options = optionsFromArguments(LFO.getDefaults(), arguments); 7 | super(options); 8 | this._retrigger = options.retrigger; 9 | } 10 | 11 | static getDefaults() { 12 | return Object.assign(ToneLFO.getDefaults(), { 13 | retrigger: false, 14 | offset: 0, // TODO LFO offset 15 | max: 1, 16 | min: -1, 17 | phase: 0 18 | }); 19 | } 20 | 21 | get retrigger() { 22 | return this._retrigger; 23 | } 24 | 25 | set retrigger(value) { 26 | this._retrigger = Boolean(value); 27 | if (value) { 28 | // this._oscillator.stop(); 29 | this.state === "started" && this._oscillator.stop(); 30 | } else { 31 | // this._oscillator.start(now()); 32 | this.state === "stopped" && this._oscillator.start(now()); 33 | } 34 | } 35 | 36 | /** 37 | * restart LFO phase 38 | */ 39 | restart() { 40 | if (this.retrigger) { 41 | this._oscillator.phase = this.phase; 42 | this._oscillator.start(now()); 43 | } 44 | } 45 | 46 | restop(time) { 47 | if (this.retrigger) { 48 | this.stop(time); 49 | } 50 | } 51 | dispose() { 52 | super.dispose(); 53 | return this; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Synth/nodes/VCO.js: -------------------------------------------------------------------------------- 1 | import { FatOscillator, optionsFromArguments, Oscillator } from "tone"; 2 | import { noOp } from "tone/build/esm/core/util/Interface"; 3 | //==================== 4 | import { MODULATION } from "@/Synth/nodes"; 5 | import { clamp, random } from "@/utils"; 6 | 7 | export default class VCO extends FatOscillator { 8 | constructor() { 9 | const options = optionsFromArguments(VCO.getDefaults(), arguments); 10 | super(options); 11 | 12 | this._fine = options.fine; 13 | this._semi = options.semi; 14 | this._octave = 0; 15 | 16 | this._retrigger = options.retrigger; 17 | 18 | // configure modulatable parameters 19 | this.detuneNode = new MODULATION({ 20 | exp: 3, 21 | units: "cents", 22 | value: 0, 23 | modRange: options.modPitchRange 24 | }); 25 | this.detuneNode.output.connect(this.detune); 26 | 27 | /* ====================== */ 28 | this.octave = options.octave; // trigger initial detuning (only once) 29 | } 30 | 31 | static getDefaults() { 32 | return Object.assign(FatOscillator.getDefaults(), { 33 | frequency: "C4", 34 | type: "sine", 35 | count: 0, // num of osc's 36 | phase: 0, 37 | modPitchRange: 4800, //cents 38 | spread: 0, // detune between osc's 39 | // ----- 40 | fine: 0, 41 | semi: 0, 42 | octave: 0, 43 | // stereo: 0, // stereo spread 44 | retrigger: false 45 | }); 46 | } 47 | 48 | get fine() { 49 | return this._fine; 50 | } 51 | 52 | set fine(value) { 53 | // ±100 cents range 54 | value = clamp(parseInt(value), -100, 100); 55 | this._fine = value; 56 | this.detuneOsc(); 57 | } 58 | get semi() { 59 | return this._semi; 60 | } 61 | set semi(value) { 62 | // ±12 semis range (±1200 cents) 63 | value = clamp(parseInt(value), -12, 12); 64 | this._semi = value; 65 | this.detuneOsc(); 66 | } 67 | get octave() { 68 | return this._octave; 69 | } 70 | set octave(value) { 71 | // ±3 octaves range (±3600 cents) 72 | value = clamp(parseInt(value), -3, 3); 73 | this._octave = value; 74 | this.detuneOsc(); 75 | } 76 | get retrigger() { 77 | return this._retrigger; 78 | } 79 | set retrigger(value) { 80 | this._retrigger = Boolean(value); 81 | } 82 | get phase() { 83 | return this._phase; 84 | } 85 | 86 | set phase(phase) { 87 | this._phase = phase; 88 | // retrigger sync voices phase 89 | if (this.retrigger) { 90 | this._forEach(osc => (osc.phase = phase)); 91 | } 92 | } 93 | 94 | get count() { 95 | return this._oscillators.length; 96 | } 97 | 98 | /* 99 | custom count setter that allows to set oscillators to 0 100 | */ 101 | set count(count) { 102 | // assertRange(count, 1); 103 | if (this._oscillators.length !== count) { 104 | // dispose the previous oscillators 105 | this._forEach(osc => osc.dispose()); 106 | this._oscillators = []; 107 | for (let i = 0; i < count; i++) { 108 | const osc = new Oscillator({ 109 | context: this.context, 110 | volume: -6 - count * 1.1, 111 | type: this._type, 112 | phase: this._phase + (i / count) * 360, 113 | partialCount: this._partialCount, 114 | onstop: i === 0 ? () => this.onstop(this) : noOp 115 | }); 116 | if (this.type === "custom") { 117 | osc.partials = this._partials; 118 | } 119 | this.frequency.connect(osc.frequency); 120 | this.detune.connect(osc.detune); 121 | osc.detune.overridden = false; 122 | osc.connect(this.output); 123 | this._oscillators[i] = osc; 124 | } 125 | // set the spread 126 | this.spread = this._spread; 127 | if (this.state === "started") { 128 | this._forEach(osc => osc.start()); 129 | } 130 | } 131 | } 132 | 133 | /* 134 | detune the oscillator freq using three independent parameters: 135 | - fine: ±100 cents range, 136 | - semi: ±12 notes range, 137 | - octave: ±3 octaves range. 138 | */ 139 | detuneOsc() { 140 | const fine = this._fine, 141 | semi = this._semi * 100, 142 | octave = this._octave * 1200, 143 | value = fine + semi + octave; 144 | this.detuneNode._signal.setValueAtTime(value); 145 | } 146 | 147 | _start(time) { 148 | // // randomize voices phase on start 149 | if (this.retrigger) { 150 | this._forEach(osc => (osc.phase = this.phase)); 151 | } else { 152 | this._forEach(osc => (osc.phase = random(0, 360))); 153 | } 154 | super._start(time); 155 | } 156 | 157 | setNote(note, time) { 158 | this.frequency.setValueAtTime(note, time); 159 | } 160 | 161 | dispose() { 162 | super.dispose(); 163 | this.detuneNode.disconnect(); 164 | this.detuneNode.dispose(); 165 | return this; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/Synth/nodes/index.js: -------------------------------------------------------------------------------- 1 | import LFO from "./LFO"; 2 | import VCO from "./VCO"; 3 | import FILTER from "./FILTER"; 4 | import MODULATION from "./mod"; 5 | import FX from "./FX"; 6 | import DISTORTION from "./Distortion"; 7 | import ENVELOPE from "./ENVELOPE"; 8 | 9 | export { LFO, VCO, FILTER, MODULATION, FX, DISTORTION, ENVELOPE }; 10 | -------------------------------------------------------------------------------- /src/Synth/nodes/mod.js: -------------------------------------------------------------------------------- 1 | import { 2 | optionsFromArguments, 3 | Multiply, 4 | ToneAudioNode, 5 | Signal, 6 | WaveShaper 7 | } from "tone"; 8 | 9 | export class ModNode extends ToneAudioNode { 10 | constructor() { 11 | super(optionsFromArguments(ModNode.getDefaults(), arguments)); 12 | const options = optionsFromArguments(ModNode.getDefaults(), arguments); 13 | 14 | this._signal = this.input = new Signal({ units: options.units, value: options.value }); 15 | 16 | this.modNode = new Multiply({ value: options.modRange }); 17 | this.output = new Signal({ units: options.units }); 18 | this.input.connect(this.output); 19 | this.modNode.connect(this.output); 20 | } 21 | get raw() { 22 | return this.meter.getValue(); 23 | } 24 | 25 | static getDefaults() { 26 | return Object.assign(ToneAudioNode.getDefaults(), { 27 | modRange: 1, 28 | units: "number", 29 | value: 0 30 | }); 31 | } 32 | dispose() { 33 | super.dispose(); 34 | this._signal.disconnect(); 35 | this.output.disconnect(); 36 | this.modNode.disconnect(); 37 | this.modNode.dispose(); 38 | return this; 39 | } 40 | } 41 | class ModNodeExp extends ModNode { 42 | constructor() { 43 | super(optionsFromArguments(ModNodeExp.getDefaults(), arguments)); 44 | const options = optionsFromArguments(ModNodeExp.getDefaults(), arguments); 45 | 46 | this._modMultNode = new Multiply({ value: options.modRange }).connect(this.output); 47 | this.modNode = new WaveShaper(val => Math.pow(val, options.exp)).connect(this._modMultNode); 48 | } 49 | static getDefaults() { 50 | return Object.assign(ModNode.getDefaults(), { 51 | exp: 1 52 | }); 53 | } 54 | dispose() { 55 | super.dispose(); 56 | this._modMultNode.disconnect(); 57 | this._modMultNode.dispose(); 58 | return this; 59 | } 60 | } 61 | 62 | export default class ModNodeFactory { 63 | constructor(options) { 64 | if (options.exp) { 65 | return new ModNodeExp(options); 66 | } else { 67 | return new ModNode(options); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Synth/presets.js: -------------------------------------------------------------------------------- 1 | // paste your presets here 2 | 3 | export default []; 4 | -------------------------------------------------------------------------------- /src/Synth/synthfunctions.js: -------------------------------------------------------------------------------- 1 | "use-strict"; 2 | 3 | import { clamp, createEnum } from "@/utils"; 4 | 5 | /** 6 | * calculates the bipolar (-1 -> +1) value from a normalized unipolar (0 -> 1) value 7 | * @param {Number} value value to convert 8 | */ 9 | export function unipolarToBipolar(value) { 10 | return 2 * value - 1; 11 | } 12 | 13 | /** 14 | * calculates the normalized unipolar (0 -> 1) value from a bipolar (-1 -> +1) value 15 | * @param {Number} value value to convert 16 | */ 17 | export function bipolarToUnipolar(value) { 18 | return 0.5 * value + 0.5; 19 | } 20 | 21 | /** 22 | * calculates dB gain from normalized 0->1 scale 23 | * @param {Number} gain - Gain multiplier 0 -> 1; 24 | * @param {Number} [dBMin=-96] - Min dB value; 25 | * @param {Number} [dBMax=0] - Offset dB scale; 26 | * dB = 20 · log10(g) 27 | */ 28 | export function normalizedInToDB(gain, dbMin = -96, dBMax = 0) { 29 | if (gain <= 0) return dbMin; // log10 require value > 0 30 | return 20 * Math.log10(gain) + dBMax; 31 | } 32 | 33 | /** 34 | * Calculates linear normalized 0-1 value from dB value \ 35 | * formula -> g = 10^(dB / 20) 36 | * @param {Number} dValue - dB value; 37 | * @param {Number} [dBMin=-96] - Min dB value; 38 | * @param {Number} [dBMax=0] - Max dB value; 39 | */ 40 | export function dBToNormalized(dValue, dBMin = -96, dBMax = 0) { 41 | if (dValue <= dBMin) return 0; 42 | if (dValue >= dBMax) return 1; 43 | return Math.pow(10, (dValue - dBMax) / 20); 44 | } 45 | 46 | const sourcesArr = [ 47 | "SOURCE_NONE", 48 | "LFO_1_OUT", 49 | "LFO_2_OUT", 50 | "AMP_EG_OUT", 51 | "EG_1_OUT", 52 | "MAX_SOURCES" 53 | ]; 54 | 55 | const destArr = [ 56 | "DEST_NONE", 57 | 58 | "OSC_12_PITCH", 59 | "AMP_VOL", 60 | 61 | "OSC_1_PITCH", // ----- per voice 62 | "OSC_1_PHASE", 63 | "OSC_1_VOL", 64 | "OSC_1_PAN", 65 | 66 | "OSC_2_PITCH", 67 | "OSC_2_PHASE", 68 | "OSC_2_VOL", 69 | "OSC_2_PAN", 70 | 71 | "OSC_3_VOL", 72 | "OSC_3_PAN", 73 | 74 | "FILTER_CUT", 75 | "OSC_123_VOL", 76 | "OSC_12_PITCH", 77 | 78 | "MAX_DESTINATIONS" 79 | ]; 80 | const transformsArr = [ 81 | "NONE", 82 | "UNIPOLAR_TO_BIPOLAR", 83 | "BIPOLAR_TO_UNIPOLAR", 84 | "NOTE_NUMBER_TO_FREQUENCY" 85 | ]; 86 | 87 | const controllersArr = [ 88 | "MASTER_GAIN", // 0 89 | "OSC_1_TP", 90 | "OSC_1_OCT", // 91 | "OSC_1_SEMI", 92 | "OSC_1_FINE", 93 | "OSC_1_PHASE", 94 | "OSC_1_VOICE", 95 | "OSC_1_SPRD", 96 | "OSC_1_RETR", 97 | 98 | "OSC_1_VOL", // 9 99 | "OSC_1_PAN", 100 | // ----------- 101 | "OSC_2_TP", // 11 102 | "OSC_2_OCT", 103 | "OSC_2_SEMI", 104 | "OSC_2_FINE", 105 | "OSC_2_PHASE", 106 | "OSC_2_VOICE", 107 | "OSC_2_SPRD", 108 | "OSC_2_RETR", 109 | 110 | "OSC_2_VOL", // 19 111 | "OSC_2_PAN", 112 | // ----------- 113 | "OSC_3_VOL", // 21 114 | "OSC_3_PAN", 115 | // ----------- 116 | "FILTER_CUT", // 23 117 | "FILTER_Q", 118 | "FILTER_EG", 119 | "FILTER_TP", 120 | // ----------- 121 | "AMP_EG_ATT", // 27 122 | "AMP_EG_DEC", 123 | "AMP_EG_SUS", 124 | "AMP_EG_REL", 125 | // ----------- 126 | "EG_1_ATT", // 31 127 | "EG_1_DEC", 128 | "EG_1_SUS", 129 | "EG_1_REL", 130 | // ----------- 131 | "LFO_1_FC", // 35 132 | "LFO_1_TP", 133 | "LFO_1_GAIN", 134 | "LFO_1_PHASE", 135 | "LFO_1_RETR", 136 | // ----------- 137 | "LFO_2_FC", // 40 138 | "LFO_2_TP", 139 | "LFO_2_GAIN", 140 | "LFO_2_PHASE", 141 | "LFO_2_RETR", 142 | // ----------- 143 | "FX1_ON", //45 //bypass effect 144 | "FX1_WET", // dry/wet 145 | "FX1_1", 146 | "FX1_2", 147 | "FX2_ON", //bypass effect 148 | "FX2_WET", // dry/wet 149 | "FX2_1", 150 | "FX2_2", 151 | "FX2_3", 152 | "FX2_4", 153 | "FX2_5", 154 | "FX3_ON", //bypass effect 155 | "FX3_WET", // dry/wet 156 | "FX3_1", 157 | "FX3_2", 158 | "FX4_ON", //bypass effect 159 | "FX4_WET", // dry/wet 160 | "FX4_1", 161 | "FX4_2", 162 | "FX5_ON", //bypass effect limiter 163 | "FX5_1", 164 | "M_ROW_1", //66 165 | "M_ROW_3", 166 | "M_ROW_2", 167 | "M_ROW_4", 168 | "M_ROW_5" 169 | // ----------- 170 | ]; 171 | 172 | /* 173 | ========================== 174 | distortion algorithms 175 | ========================== 176 | */ 177 | /** 178 | * A favourite of mine is using a sin() function instead. 179 | * This will have the "unfortunate" side effect of removing 180 | * odd harmonics if you take it to the extreme: a triangle 181 | * wave gets mapped to a pure sine wave. 182 | * https://www.musicdsp.org/en/latest/Effects/43-waveshaper.html 183 | * @param {Number} k amount [0, 1] 184 | * @param {Number} x input [-1, 1] 185 | * @author Jon Watte 186 | */ 187 | const sine = function(k) { 188 | k = k * 0.4 + 0.1; // soft sine-shaping effect [0.1, 0.5] 189 | var z = Math.PI * k, 190 | sin = Math.sin, 191 | s = clamp(1 / sin(z), -3.236068, 3.236068); /* otherwise blowup */ 192 | return function(x) { 193 | return sin(z * x) * s; 194 | }; 195 | }; 196 | /** 197 | * https://www.musicdsp.org/en/latest/Effects/86-waveshaper-gloubi-boulga.html 198 | * @param {Number} k amount [0, 1] 199 | * @param {Number} x input [-1, 1] 200 | * @author Laurent de Soras 201 | */ 202 | const gloubiboulga = function(k) { 203 | var abs = Math.abs, 204 | exp = Math.exp, 205 | sqrt = Math.sqrt; 206 | k = k * 20 + 1; // k range [1, Inf] 207 | return function(x) { 208 | // x*=0.686306; 209 | x = x * k * 0.686306; 210 | var a = 1 + exp(sqrt(abs(x)) * -0.75); 211 | return (exp(x) - exp(-x * a)) / (exp(x) + exp(-x)); 212 | }; 213 | }; 214 | /** 215 | * https://www.musicdsp.org/en/latest/Effects/203-fold-back-distortion.html 216 | * @param {Number} x input [-1, 1] 217 | * @param {Number} k threshold [0, 1] 218 | */ 219 | const foldback = function(k) { 220 | k = clamp(k, 0, 0.999); 221 | k = 1 - k; // inverse threshold to apply relation -> more = stronger effect 222 | var abs = Math.abs; 223 | return function(x) { 224 | if (x > k || x < -k) { 225 | x = abs(abs((x - k) % (k * 4)) - k * 2) - k; 226 | } 227 | return x; 228 | }; 229 | }; 230 | 231 | /** 232 | * soft clipping algorithm 233 | * @param {Number} x input [-1, 1] 234 | * @param {Number} k clipping factor [0, 1] (0 = none, infinity = hard) 235 | */ 236 | const softClip = function(k) { 237 | return function(x) { 238 | var x1 = x * k, 239 | x2 = x1 * x1 + 0.25; 240 | return x1 / x2; 241 | }; 242 | }; 243 | 244 | /** 245 | * https://www.musicdsp.org/en/latest/Effects/42-soft-saturation.html 246 | * @param {Number} x input [-1, 1] 247 | * @param {Number} k amount [0, 1] 248 | */ 249 | const saturator = function(k) { 250 | k = 1 - k; 251 | var abs = Math.abs, 252 | sign = Math.sign, 253 | absx; 254 | return function(x) { 255 | absx = abs(x); 256 | // adjust to negative waveform values 257 | if (x > k || x < -k) { 258 | return sign(x) * (k + (absx - k) / (1 + ((absx - k) / (1 - k)) ** 2)); 259 | } else if (x > 1) { 260 | return (sign(x) * (k + 1)) / 2; 261 | } 262 | return x; 263 | }; 264 | }; 265 | 266 | /** 267 | * Bit crusher algorithm simplified to one parameter 268 | * @param {Number} x input [-1, 1] 269 | * @param {Number} k amount [0, 1] 270 | * @description reduce sample rate in range 16 bit -> 1 bit 271 | */ 272 | const crusher = function(k) { 273 | k = 1 - k; 274 | k *= 16; 275 | const m = 2 ** k, 276 | round = Math.round; 277 | return function(x) { 278 | return round(x * m) / m; 279 | }; 280 | }; 281 | 282 | /** 283 | * Waveshaper \ 284 | * https://www.musicdsp.org/en/latest/Effects/46-waveshaper.html 285 | * @param {Number} x input [-1, 1] 286 | * @param {Number} k amount [0, 1] 287 | * @author Partice Tarrabia and Bram de Jong 288 | */ 289 | const tarrabia = function(k) { 290 | k = clamp(k, 0, 0.997); 291 | const m = (2 * k) / (1 - k), 292 | abs = Math.abs; 293 | return function(x) { 294 | return ((1 + m) * x) / (1 + m * abs(x)); 295 | }; 296 | }; 297 | 298 | /** 299 | * https://www.musicdsp.org/en/latest/Effects/41-waveshaper.html 300 | * @param {Number} x input [-1, 1] 301 | * @param {Number} k clipping factor [0, 1] (0 = none, infinity = hard) 302 | * @author Bram de Jong 303 | */ 304 | function fuzz(k) { 305 | k = k * 20 + 1; 306 | const abs = Math.abs, 307 | pow = Math.pow; 308 | let absx; 309 | return function(x) { 310 | absx = abs(x); 311 | return (x * (absx + k)) / (pow(x, 2) + (k - 1) * absx + 1); 312 | }; 313 | } 314 | 315 | export const SOURCES = createEnum(sourcesArr); 316 | export const DESTINATIONS = createEnum(destArr); 317 | export const TRANSFORMS = createEnum(transformsArr); 318 | export const CONTROLLERS = createEnum(controllersArr, true); 319 | export const CONTROL_TYPES = createEnum([ 320 | "KNOB", 321 | "DISPLAYPICKER", 322 | "FADER", 323 | "WAVEPICKER", 324 | "TOGGLE", 325 | "SELECT" 326 | ]); 327 | 328 | export const DISTORTION_ALGORITHMS = Object.freeze([ 329 | sine, 330 | gloubiboulga, 331 | foldback, 332 | softClip, 333 | saturator, 334 | crusher, 335 | tarrabia, 336 | fuzz 337 | ]); 338 | -------------------------------------------------------------------------------- /src/assets/fonts/OpenSansPX.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Razz21/vue-synth/0069cf46366d12140b085f4809f161f5f0e845c1/src/assets/fonts/OpenSansPX.ttf -------------------------------------------------------------------------------- /src/assets/fonts/OpenSansPXBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Razz21/vue-synth/0069cf46366d12140b085f4809f161f5f0e845c1/src/assets/fonts/OpenSansPXBold.ttf -------------------------------------------------------------------------------- /src/assets/images/wood.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Razz21/vue-synth/0069cf46366d12140b085f4809f161f5f0e845c1/src/assets/images/wood.jpg -------------------------------------------------------------------------------- /src/assets/scss/_fonts.scss: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;800;900&display=swap"); 2 | 3 | @font-face { 4 | font-family: "OpenSansPX"; 5 | font-weight: normal; 6 | font-style: normal; 7 | src: url("~@/assets/fonts/OpenSansPX.ttf") format("truetype"); 8 | } 9 | 10 | @font-face { 11 | font-family: "OpenSansPXBold"; 12 | font-weight: bold; 13 | font-style: normal; 14 | src: url("~@/assets/fonts/OpenSansPXBold.ttf") format("truetype"); 15 | } 16 | -------------------------------------------------------------------------------- /src/assets/scss/_variables.scss: -------------------------------------------------------------------------------- 1 | $primary-control: #f08f20; 2 | // $primary: rgb(35, 243, 118); 3 | // $primary: #00e7ff; 4 | $primary: #ff0080; 5 | 6 | // https://til.hashrocket.com/posts/sxbrscjuqu-share-scss-variables-with-javascript 7 | :export { 8 | primary: $primary; 9 | control: $primary-control; 10 | } 11 | -------------------------------------------------------------------------------- /src/assets/scss/global.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | @import "fonts"; 3 | 4 | // override built-it min method to handle different units 5 | @function min($numbers...) { 6 | @return m#{i}n(#{$numbers}); 7 | } 8 | 9 | // override built-it min method to handle different units 10 | @function max($numbers...) { 11 | @return m#{a}x(#{$numbers}); 12 | } 13 | -------------------------------------------------------------------------------- /src/assets/scss/main.scss: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | margin: 0; 4 | padding: 0; 5 | position: relative; 6 | } 7 | 8 | html, 9 | body, 10 | #app { 11 | height: 100%; 12 | width: 100%; 13 | overflow: hidden; 14 | } 15 | 16 | body { 17 | -webkit-transition: 0s; 18 | transition: 0s; 19 | font-family: "Calibri", "Trebuchet MS", sans-serif; 20 | background-color: #181b1c; 21 | background-color: #000; 22 | color: #e4e8ea; 23 | -webkit-font-smoothing: antialiased; 24 | font-size: 18px; 25 | font-size: 20px; 26 | 27 | // font-size: clamp(18px, 1.5vw, 20px); 28 | } 29 | 30 | #app { 31 | display: flex; 32 | } 33 | 34 | ul, 35 | li { 36 | list-style: none; 37 | } 38 | 39 | select { 40 | color: inherit; 41 | cursor: pointer; 42 | text-transform: uppercase; 43 | white-space: nowrap; 44 | text-overflow: ellipsis; 45 | text-align: center; 46 | text-align-last: center; 47 | width: 100%; 48 | height: 100%; 49 | background: transparent; 50 | 51 | display: block; 52 | -webkit-appearance: none; 53 | appearance: none; 54 | border: none; 55 | 56 | option { 57 | color: initial; 58 | font-size: 0.9em; 59 | width: 100%; 60 | } 61 | 62 | &::-ms-expand { 63 | display: none; 64 | } 65 | 66 | &:focus, 67 | &:active { 68 | outline: none; 69 | } 70 | } 71 | 72 | .handle { 73 | cursor: grab; 74 | 75 | &:active, 76 | &:focus { 77 | cursor: pointer !important; 78 | } 79 | } 80 | 81 | .flex { 82 | display: flex; 83 | } 84 | 85 | .flex-column { 86 | flex-direction: column; 87 | } 88 | 89 | .flex-1 { 90 | flex: 1; 91 | } 92 | 93 | .overflow-hidden { 94 | overflow: hidden; 95 | } 96 | 97 | .justify-center { 98 | justify-content: center; 99 | } 100 | 101 | .justify-between { 102 | justify-content: space-between; 103 | } 104 | 105 | .align-center { 106 | align-items: center; 107 | } 108 | 109 | .h-full { 110 | height: 100%; 111 | } 112 | 113 | .w-full { 114 | width: 100%; 115 | } 116 | 117 | .m-auto { 118 | margin: auto; 119 | } 120 | -------------------------------------------------------------------------------- /src/components/Piano.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 214 | 215 | 285 | -------------------------------------------------------------------------------- /src/components/Synthesizer.vue: -------------------------------------------------------------------------------- 1 | 168 | 169 | 249 | 250 | 527 | -------------------------------------------------------------------------------- /src/components/controllers/BaseController.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 23 | 24 | 79 | -------------------------------------------------------------------------------- /src/components/controllers/Controller.vue: -------------------------------------------------------------------------------- 1 | 63 | -------------------------------------------------------------------------------- /src/components/controllers/DisplayPicker.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/components/controllers/Fader.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 51 | 52 | 194 | -------------------------------------------------------------------------------- /src/components/controllers/Knob.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 110 | 111 | 173 | -------------------------------------------------------------------------------- /src/components/controllers/Toggle.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 80 | -------------------------------------------------------------------------------- /src/components/controllers/VSelect.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 44 | 45 | 88 | -------------------------------------------------------------------------------- /src/components/controllers/WavePicker.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 37 | 38 | 118 | -------------------------------------------------------------------------------- /src/components/controllers/__tests__/BaseController.spec.js: -------------------------------------------------------------------------------- 1 | import BaseController from "../BaseController.vue"; 2 | import { shallowMount } from "@vue/test-utils"; 3 | 4 | describe("BaseController.vue", () => { 5 | test("renders label", () => { 6 | const propsData = { label: "some label" }; 7 | const wrapper = shallowMount(BaseController, { propsData }); 8 | expect(wrapper.text()).toContain(propsData.label); 9 | }); 10 | test("do not render label div if label prop not provided", () => { 11 | const propsData = {}; 12 | const wrapper = shallowMount(BaseController, { propsData }); 13 | expect(wrapper.find(".controller__label").exists()).toBe(false); 14 | }); 15 | test("renders label position class", () => { 16 | const propsData = { label: "some label", labelPosition: "left" }; 17 | const wrapper = shallowMount(BaseController, { propsData }); 18 | expect(wrapper.classes()).toContain("label-" + propsData.labelPosition); 19 | }); 20 | test("validator label position", () => { 21 | const validTypes = ["top", "bottom", "left", "right"]; 22 | const validator = BaseController.props.labelPosition.validator; 23 | validTypes.forEach(type => expect(validator(type)).toBe(true)); 24 | 25 | expect(validator("position")).toBe(false); 26 | }); 27 | // todo test bind class attribute 28 | // test("render static class", () => { 29 | // const attrs = { class: "custom--class" }; 30 | // const wrapper = shallowMount(BaseController, { attrs }); 31 | // expect(wrapper.classes()).toContain(attrs.class); 32 | // }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/components/controllers/__tests__/Controller.spec.js: -------------------------------------------------------------------------------- 1 | import Controller from "../Controller.vue"; 2 | import { shallowMount } from "@vue/test-utils"; 3 | import { Control } from "@/Synth/control"; 4 | 5 | jest.mock("@/Synth/control"); 6 | 7 | describe("Controller.vue", () => { 8 | // let model; 9 | // beforeEach(() => { 10 | // // Clear all instances and calls to constructor and all methods: 11 | 12 | // }); 13 | 14 | test("validate model prop", () => { 15 | const msg = Controller.props.model; 16 | expect(msg.required).toBeTruthy(); 17 | expect(msg.type).toBe(Control); 18 | }); 19 | test("render component", () => { 20 | const model = new Control(); 21 | const wrapper = shallowMount(Controller, { propsData: { model } }); 22 | expect(wrapper.isVueInstance()).toBe(true); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/components/controllers/__tests__/DisplayPicker.spec.js: -------------------------------------------------------------------------------- 1 | import DisplayPicker from "../DisplayPicker.vue"; 2 | import { shallowMount } from "@vue/test-utils"; 3 | 4 | describe("DisplayPicker.vue", () => { 5 | let model; 6 | beforeEach(() => { 7 | model = jest.mock(); 8 | }); 9 | test("render values", async () => { 10 | const propsData = { step: 1, snap: true, default: 1, value: 0 }; 11 | const wrapper = shallowMount(DisplayPicker, { propsData }); 12 | await wrapper.find(".handle").trigger("dblclick"); 13 | expect(wrapper.emitted().input).toBeTruthy(); 14 | expect(wrapper.emitted().input.length).toBe(1); 15 | expect(wrapper.emitted("input")[0]).toEqual([propsData.default]); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/components/display/DisplayBase.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 20 | 21 | 56 | -------------------------------------------------------------------------------- /src/components/display/LedDisplay.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 32 | 33 | 52 | -------------------------------------------------------------------------------- /src/components/global/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Razz21/vue-synth/0069cf46366d12140b085f4809f161f5f0e845c1/src/components/global/.keep -------------------------------------------------------------------------------- /src/components/matrix/Matrix.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 68 | 69 | 108 | -------------------------------------------------------------------------------- /src/components/matrix/MatrixRow.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 73 | 74 | 120 | -------------------------------------------------------------------------------- /src/components/matrix/MatrixRowAmount.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/components/ui/VSection.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 31 | 32 | 70 | -------------------------------------------------------------------------------- /src/components/vis/Analyser.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 96 | 97 | 123 | -------------------------------------------------------------------------------- /src/components/vis/MainScreen.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 62 | 63 | 165 | -------------------------------------------------------------------------------- /src/components/vis/Meter.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 39 | 40 | 63 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import App from "./App.vue"; 3 | 4 | /* 5 | =========================== 6 | >>> Globally register all components from 'src/components/global' directory 7 | =========================== 8 | */ 9 | const files = require.context("@/components/global", true, /\.vue$/i); 10 | 11 | files.keys().map(key => { 12 | Vue.component( 13 | files(key).default.name ?? 14 | key 15 | .split("/") 16 | .pop() 17 | .split(".")[0], 18 | files(key).default 19 | ); 20 | }); 21 | /* 22 | =========================== 23 | */ 24 | 25 | Vue.config.productionTip = false; 26 | 27 | Vue.prototype.$bus = new Vue(); 28 | 29 | new Vue({ 30 | render: h => h(App) 31 | }).$mount("#app"); 32 | -------------------------------------------------------------------------------- /src/mixins/BaseControlMixin.js: -------------------------------------------------------------------------------- 1 | import { scale } from "@/utils"; 2 | export const BaseControlMixin = { 3 | methods: { 4 | positionToValue(position) { 5 | return scale(position, 0, 1, this.min$, this.max$); 6 | }, 7 | valueToPosition(value) { 8 | return scale(value, this.min$, this.max$, 0, 1); 9 | } 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /src/mixins/Control.js: -------------------------------------------------------------------------------- 1 | import { clamp, scale } from "@/utils"; 2 | import _merge from "lodash.merge"; 3 | import { LogControlMixin } from "@/mixins/LogControl"; 4 | import { ExpControlMixin } from "@/mixins/ExpControlMixin"; 5 | import { StepControlMixin } from "@/mixins/StepControlMixin"; 6 | import { BaseControlMixin } from "@/mixins/BaseControlMixin"; 7 | export const ControlMixin = { 8 | beforeCreate() { 9 | const { step, log, exp } = this.$options.propsData; 10 | let mixin; 11 | // _merge() override constructor $options, so changes in component are permanent 12 | // and options need to be updated every time 13 | if (step) { 14 | mixin = StepControlMixin; 15 | } else if (exp) { 16 | mixin = ExpControlMixin; 17 | } else if (log) { 18 | mixin = LogControlMixin; 19 | } else { 20 | mixin = BaseControlMixin; 21 | } 22 | // override component options with mixin 23 | _merge(this.$options, mixin); 24 | 25 | // override mixin options with component custom methods/computed etc. 26 | // _merge(this.$options, _merge({}, mixin, this.$options)); 27 | }, 28 | props: { 29 | /* 30 | id prop for presets functionality 31 | */ 32 | id: { 33 | type: [String, Number] 34 | // required: true 35 | }, 36 | value: null, 37 | /* 38 | default value 39 | */ 40 | default: { 41 | type: [Number, String], 42 | default: 0, 43 | validate: v => +v === +v // Number 44 | }, 45 | min: { 46 | type: [Number, String], 47 | default: 0, 48 | validate: v => +v === +v // Number 49 | }, 50 | max: { 51 | type: [Number, String], 52 | default: 1, 53 | validate: v => +v === +v // Number 54 | }, 55 | 56 | /* 57 | mouse drag sensitivity; more->faster 58 | */ 59 | sensitivity: { 60 | type: [Number, String], 61 | validator: v => +v === +v && v > 0, 62 | default: 1 63 | }, 64 | 65 | step: { 66 | type: [String, Number], 67 | validate: v => +v === +v && v > 0 // positive integer / float 68 | }, 69 | digits: { 70 | type: [String, Number], 71 | default: 2, 72 | validator: v => Number.isInteger(+v) && v >= 0 73 | }, 74 | units: { 75 | type: String, 76 | default: "" 77 | }, 78 | exp: { 79 | type: [Number, String], 80 | validator: v => +v !== 0 && +v === +v // differ zero 81 | }, 82 | snap: Boolean, 83 | bipolar: Boolean, 84 | log: Boolean, 85 | label: String 86 | }, 87 | data() { 88 | return { 89 | currentY$: 0, 90 | temp$: 0, 91 | selected$: false, 92 | wheelResistance: 1 // mouse wheel sensitivity; more -> slower (more accurate change) 93 | }; 94 | }, 95 | 96 | computed: { 97 | position$: { 98 | get() { 99 | return this.$_valueToPosition(this.value); 100 | }, 101 | set(position) { 102 | this.$_positionToValue(position); 103 | } 104 | }, 105 | min$() { 106 | // transform prop to number to avoid wrong string parsing in math calculations 107 | return Number(this.min); 108 | }, 109 | max$() { 110 | // transform prop to number to avoid wrong string parsing in math calculations 111 | return Number(this.max); 112 | }, 113 | $_steps() { 114 | if (this.step) { 115 | return this.$_valueSpread / this.step; 116 | } 117 | return 0; 118 | }, 119 | $_stepDelta() { 120 | if (this.$_steps > 0) { 121 | return 1 / this.$_steps; 122 | } 123 | return 1; 124 | }, 125 | $_valueSpread() { 126 | return this.max$ - this.min$; 127 | }, 128 | 129 | $_sensitivity() { 130 | return this.sensitivity / 120; 131 | }, 132 | controlHandlers() { 133 | return { 134 | mousemove: this.$_handleMouseMove, 135 | mouseup: this.$_handleMouseUp, 136 | mousedown: this.$_handleMouseDown, 137 | dblclick: this.$_handleDbClick, 138 | click: this.$_handleMouseClick 139 | }; 140 | } 141 | }, 142 | methods: { 143 | // ========================== 144 | // private methods 145 | // ========================== 146 | $_valueToDisplay(value) { 147 | let valueNumber = value.toFixed(this.digits); 148 | if (this.digits === 0 && valueNumber > 1000) { 149 | valueNumber /= 1000; 150 | valueNumber = valueNumber.toFixed(valueNumber < 10 ? 2 : 1) + "k"; 151 | } 152 | return valueNumber + this.units; 153 | }, 154 | $_positionToValue(position) { 155 | const value = this.positionToValue(position); 156 | // prevent calling handler with repeated values 157 | if (this.value !== value) { 158 | this.$emit("input", value); 159 | this.$bus.$emit("control:change", this.$_valueToDisplay(value)); 160 | } 161 | }, 162 | $_valueToPosition(value) { 163 | /* 164 | initialize controler position with provided value 165 | */ 166 | // return value in selected range (min -> max) 167 | value = clamp(value, this.min$, this.max$); 168 | return this.valueToPosition(value); 169 | }, 170 | $_updatePosition(position) { 171 | const _position = clamp(position, 0, 1); 172 | 173 | if (_position !== this.position$) { 174 | this.position$ = _position; 175 | } 176 | }, 177 | $_init() { 178 | this.$_clearDrag(); 179 | this.init(); 180 | }, 181 | $_clearDrag() { 182 | this.selected$ = false; 183 | window.removeEventListener("mousemove", this.$_handleMouseMove); 184 | window.removeEventListener("mouseup", this.$_handleMouseUp); 185 | }, 186 | $_resetToDefault() { 187 | this.$emit("input", this.default); 188 | }, 189 | // ========================== 190 | // public methods 191 | // ========================== 192 | init() {}, 193 | positionToValue(position) { 194 | return scale(position, 0, 1, this.min$, this.max$); 195 | }, 196 | valueToPosition(value) { 197 | return scale(value, this.min$, this.max$, 0, 1); 198 | }, 199 | // ========================== 200 | // user interaction handlers 201 | // ========================== 202 | $_handleMouseMove(e) { 203 | if (!this.selected$) return; 204 | 205 | let position = this.position$, 206 | delta; 207 | // Controller position 208 | delta = (e.pageY - this.currentY$) * this.$_sensitivity; 209 | if (e.shiftKey) { 210 | delta *= 0.1; 211 | } 212 | if (this.snap) { 213 | // snap to step 214 | const diff = Math.floor(delta / this.$_stepDelta) * this.$_stepDelta; 215 | position = this.temp$ - diff; 216 | } else if (delta) { 217 | position -= delta; 218 | this.currentY$ = e.pageY; 219 | } else { 220 | return; 221 | } 222 | 223 | // console.log("mouse-move", position); 224 | this.$_updatePosition(position); 225 | }, 226 | $_handleMouseUp(e) { 227 | // console.log("mouse-up"); 228 | this.currentY$ = e.pageY; 229 | this.temp$ = 0; 230 | this.$_clearDrag(); 231 | }, 232 | $_handleMouseDown(e) { 233 | this.selected$ = true; 234 | this.currentY$ = e.pageY; 235 | this.temp$ = this.position$; 236 | e.preventDefault(); 237 | 238 | window.addEventListener("mousemove", this.$_handleMouseMove); 239 | window.addEventListener("mouseup", this.$_handleMouseUp); 240 | }, 241 | $_handleDbClick() { 242 | this.$_resetToDefault(); 243 | }, 244 | $_handleMouseWheel(e) { 245 | e.preventDefault(); 246 | this.selected$ = true; 247 | this.$_clearDrag(); 248 | const sign = (e.deltaY || -e.wheelDelta || e.detail) >> 10 || 1; 249 | let position = this.position$ - 0.05 * sign; // / this.wheelResistance; 250 | this.$_updatePosition(position); 251 | }, 252 | $_handleMouseClick(e) { 253 | this.$bus.$emit("control:change", this.$_valueToDisplay(this.value)); 254 | } 255 | }, 256 | mounted() { 257 | this.$_init(); 258 | } 259 | }; 260 | -------------------------------------------------------------------------------- /src/mixins/Defer.js: -------------------------------------------------------------------------------- 1 | export default function(count = 10) { 2 | return { 3 | data() { 4 | return { 5 | displayPriority: 0 6 | }; 7 | }, 8 | mounted() { 9 | this.runDisplayPriority(); 10 | }, 11 | methods: { 12 | runDisplayPriority() { 13 | const step = () => { 14 | requestAnimationFrame(() => { 15 | this.displayPriority++; 16 | if (this.displayPriority < count) { 17 | step(); 18 | } 19 | }); 20 | }; 21 | step(); 22 | }, 23 | defer(priority) { 24 | return this.displayPriority >= priority; 25 | } 26 | } 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/mixins/ExpControlMixin.js: -------------------------------------------------------------------------------- 1 | export const ExpControlMixin = { 2 | methods: { 3 | positionToValue(position) { 4 | return this.min$ + Math.pow(position, this.exp) * this.$_valueSpread; 5 | }, 6 | valueToPosition(value) { 7 | return Math.pow((value - this.min$) / this.$_valueSpread, 1 / this.exp); 8 | } 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /src/mixins/LogControl.js: -------------------------------------------------------------------------------- 1 | export const LogControlMixin = { 2 | methods: { 3 | positionToValue(position) { 4 | const min = this.min$ === 0 ? 0.001 : this.min$; 5 | const max = this.max$ === 0 ? this.max$ - 0.001 : this.max$; 6 | return Math.exp(Math.log(min) + position * (Math.log(max) - Math.log(min))); 7 | }, 8 | valueToPosition(value) { 9 | const min = this.min$ === 0 ? 0.001 : this.min$; 10 | const max = this.max$ === 0 ? this.max$ - 0.001 : this.max$; 11 | return (Math.log(value) - Math.log(min)) / (Math.log(max) - Math.log(min)); 12 | } 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /src/mixins/StepControlMixin.js: -------------------------------------------------------------------------------- 1 | import { scale } from "@/utils"; 2 | export const StepControlMixin = { 3 | methods: { 4 | positionToValue(position) { 5 | const res = ~~((position * this.$_valueSpread) / this.step) * this.step + this.min$; 6 | return res; 7 | }, 8 | valueToPosition(value) { 9 | return scale(value, this.min$, this.max$, 0, 1); 10 | } 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/utils/__tests__/utils.spec.js: -------------------------------------------------------------------------------- 1 | import * as utils from "../index"; 2 | 3 | describe("utils", () => { 4 | describe("clamp", () => { 5 | it("should clip lower range", () => { 6 | expect(utils.clamp(-10, 0, 10)).toBe(0); 7 | }); 8 | it("should clip upper range", () => { 9 | expect(utils.clamp(20, 0, 10)).toBe(10); 10 | }); 11 | it("should not clip if value in range", () => { 12 | expect(utils.clamp(0, 0, 10)).toBe(0); 13 | expect(utils.clamp(5, 0, 10)).toBe(5); 14 | expect(utils.clamp(10, 0, 10)).toBe(10); 15 | }); 16 | }); 17 | describe("scale", () => { 18 | it("should return linearly rescaled value in new range", () => { 19 | expect(utils.scale(0, 0, 1, 5, 10)).toBe(5); 20 | expect(utils.scale(0.5, 0, 1, 5, 10)).toBe(7.5); 21 | expect(utils.scale(1, 0, 1, 5, 10)).toBe(10); 22 | }); 23 | }); 24 | describe("polarToCartesian", () => { 25 | it("should convert polar coordiantes to Cartesian", () => { 26 | expect(utils.polarToCartesian(50, 50, 40, 90).x).toBeCloseTo(90); 27 | expect(utils.polarToCartesian(50, 50, 40, 90).y).toBeCloseTo(50); 28 | 29 | expect(utils.polarToCartesian(50, 50, 40, 130).x).toBeCloseTo(80.64); 30 | expect(utils.polarToCartesian(50, 50, 40, 130).y).toBeCloseTo(75.71); 31 | }); 32 | }); 33 | describe("describeArc", () => { 34 | it("should return svg arc path string", () => { 35 | const path = utils.describeArc(50, 50, 40, 90, 90); 36 | const regex = new RegExp(/[MmAa0-9-,.\s]/g); 37 | expect(typeof path).toBe("string"); 38 | expect(path).toMatch(regex); 39 | }); 40 | }); 41 | describe("debounce", () => { 42 | let func, debounceFunc; 43 | beforeEach(() => { 44 | jest.useFakeTimers(); 45 | func = jest.fn(); 46 | debounceFunc = utils.debounce(func, 1000); 47 | }); 48 | it("should execute only once", () => { 49 | for (let i = 0; i < 100; i++) { 50 | debounceFunc(); 51 | } 52 | jest.runAllTimers(); 53 | expect(func).toBeCalledTimes(1); 54 | }); 55 | }); 56 | describe("throttle", () => { 57 | let func, throttledFunc; 58 | beforeEach(() => { 59 | jest.useFakeTimers(); 60 | func = jest.fn(); 61 | }); 62 | 63 | it("should execute in interval", () => { 64 | throttledFunc = utils.throttle(func, 100); 65 | const t = setInterval(() => { 66 | throttledFunc(); 67 | }, 50); 68 | jest.runTimersToTime(400); 69 | 70 | expect(func).toBeCalledTimes(3); 71 | clearInterval(t); 72 | }); 73 | it("should execute at least once with leading=true per `wait` duration", () => { 74 | throttledFunc = utils.throttle(func, 100, { leading: true }); 75 | const t = setInterval(() => { 76 | throttledFunc(); 77 | }, 50); 78 | jest.runTimersToTime(100); 79 | 80 | expect(func).toBeCalledTimes(1); 81 | clearInterval(t); 82 | }); 83 | it("should not execute even once with leading=false per `wait` duration", () => { 84 | throttledFunc = utils.throttle(func, 100, { leading: false }); 85 | const t = setInterval(() => { 86 | throttledFunc(); 87 | }, 50); 88 | jest.runTimersToTime(100); 89 | 90 | expect(func).toBeCalledTimes(0); 91 | clearInterval(t); 92 | }); 93 | it("should execute once with trailing=true per `wait` duration", () => { 94 | throttledFunc = utils.throttle(func, 100, { trailing: true, leading: false }); 95 | const t = setInterval(() => { 96 | throttledFunc(); 97 | }, 50); 98 | jest.runTimersToTime(200); 99 | 100 | expect(func).toBeCalledTimes(1); 101 | clearInterval(t); 102 | }); 103 | it("should not execute even once with trailing=false per `wait` duration", () => { 104 | throttledFunc = utils.throttle(func, 100, { trailing: false, leading: false }); 105 | const t = setInterval(() => { 106 | throttledFunc(); 107 | }, 50); 108 | jest.runTimersToTime(100); 109 | 110 | expect(func).toBeCalledTimes(0); 111 | clearInterval(t); 112 | }); 113 | }); 114 | describe("roundToNearest", () => { 115 | it("should round number to nearest step multiple", () => { 116 | expect(utils.roundToNearest(1.25, 0.5)).toBe(1.5); 117 | expect(utils.roundToNearest(1.249, 0.5)).toBe(1); 118 | }); 119 | }); 120 | describe("random", () => { 121 | it("should return random float number in specified range", () => { 122 | const min = 5, 123 | max = 10, 124 | res = utils.random(min, max); 125 | expect(res).toBeGreaterThanOrEqual(min); 126 | expect(res).toBeLessThanOrEqual(max); 127 | }); 128 | }); 129 | describe("createEnum", () => { 130 | it("should return object with autoincrementing unique values", () => { 131 | const arr = ["test1", "test2", "test3"], 132 | resEnum = utils.createEnum(arr), 133 | values = new Set(Object.values(resEnum)); 134 | expect(typeof resEnum).toBe("object"); 135 | expect(Object.isFrozen(resEnum)).toBe(true); 136 | expect(values.size).toBe(arr.length); 137 | expect(resEnum[arr[0]]).toBe(0); 138 | expect(resEnum[arr[arr.length - 1]]).toBe(arr.length - 1); 139 | }); 140 | it("should return object with key==value", () => { 141 | const arr = ["test1", "test2", "test3"], 142 | resEnum = utils.createEnum(arr, true); 143 | 144 | expect(typeof resEnum).toBe("object"); 145 | expect(Object.isFrozen(resEnum)).toBe(true); 146 | expect(resEnum[arr[0]]).toBe(arr[0]); 147 | expect(resEnum[arr[arr.length - 1]]).toBe(arr[arr.length - 1]); 148 | }); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Clamp value between an upper and lower bound. 3 | * @param {number} x input value 4 | * @param {number} min mininum value 5 | * @param {number} max maximum allowed value 6 | */ 7 | export function clamp(x, min, max) { 8 | return Math.min(Math.max(x, min), max); 9 | } 10 | 11 | /** 12 | * Map a value (x) from one range onto another 13 | * @param {number} x input value 14 | * @param {number} inMin input range min 15 | * @param {number} inMax input range max 16 | * @param {number} outMin output range min 17 | * @param {number} outMax output range max 18 | * @returns {number} value scaled linearly onto new range 19 | */ 20 | export function scale(x, inMin, inMax, outMin, outMax) { 21 | return ((x - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin; 22 | } 23 | 24 | /** 25 | * Convert polar coordinates to Cartesian 26 | * // https://en.wikipedia.org/wiki/Polar_coordinate_system#Converting_between_polar_and_Cartesian_coordinates 27 | * @param {number} centerX x center coordinate 28 | * @param {number} centerY y center coordinate 29 | * @param {number} radius radius value 30 | * @param {number} angleInDegrees arc angle in degrees 31 | * @returns {object} Cartesian coordinates 32 | */ 33 | export function polarToCartesian(centerX, centerY, radius, angleInDegrees) { 34 | var angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0; 35 | 36 | return { 37 | x: centerX + radius * Math.cos(angleInRadians), 38 | y: centerY + radius * Math.sin(angleInRadians) 39 | }; 40 | } 41 | 42 | export function describeArc(x, y, radius, startAngle, endAngle) { 43 | var start = polarToCartesian(x, y, radius, endAngle); 44 | var end = polarToCartesian(x, y, radius, startAngle); 45 | 46 | var largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1"; 47 | 48 | var d = ["M", end.x, end.y, "A", radius, radius, 0, largeArcFlag, 1, start.x, start.y].join(" "); 49 | 50 | return d; 51 | } 52 | 53 | export function debounce(func, wait = 100) { 54 | let timeout; 55 | return function(...args) { 56 | clearTimeout(timeout); 57 | timeout = setTimeout(() => { 58 | func.apply(this, args); 59 | }, wait); 60 | }; 61 | } 62 | /** 63 | * Underscore.js like implementation of throttle function \ 64 | * source: https://stackoverflow.com/a/27078401 65 | * @param {function} func - callback function 66 | * @param {number} wait - interval time between function execution calls 67 | * @param {Object|undefined} options - additional options (optional) 68 | * @param {boolean} [options.leading=true] - disable [=false] execution on the leading edge 69 | * @param {boolean} [options.trailing=true] - disable [=false] execution on the trailing edge 70 | * @description Returns a function, that, when invoked, will only be triggered at most once 71 | * during a given window of time. Normally, the throttled function will run 72 | * as much as it can, without ever going more than once per `wait` duration; 73 | * but if you'd like to disable the execution on the leading edge, pass 74 | * `{leading: false}`. To disable execution on the trailing edge, ditto. 75 | */ 76 | export function throttle(func, wait, options) { 77 | var context, args, result; 78 | var timeout = null; 79 | var previous = 0; 80 | if (!options) options = {}; 81 | var later = function() { 82 | previous = options.leading === false ? 0 : Date.now(); 83 | timeout = null; 84 | result = func.apply(context, args); 85 | if (!timeout) context = args = null; 86 | }; 87 | return function() { 88 | var now = Date.now(); 89 | if (!previous && options.leading === false) previous = now; 90 | var remaining = wait - (now - previous); 91 | context = this; 92 | args = arguments; 93 | if (remaining <= 0 || remaining > wait) { 94 | if (timeout) { 95 | clearTimeout(timeout); 96 | timeout = null; 97 | } 98 | previous = now; 99 | result = func.apply(context, args); 100 | if (!timeout) context = args = null; 101 | } else if (!timeout && options.trailing !== false) { 102 | timeout = setTimeout(later, remaining); 103 | } 104 | return result; 105 | }; 106 | } 107 | 108 | /** 109 | * Round input to closest step multiple value 110 | * @param {number} num input value 111 | * @param {number} step step value 112 | */ 113 | export function roundToNearest(num, step) { 114 | return Math.round(num / step) * step; 115 | } 116 | 117 | /** 118 | * Generate random number (float) in given range 119 | * @param {number} min min range value (inc) 120 | * @param {number} max max range value (inc) 121 | */ 122 | export function random(min, max) { 123 | return Math.random() * (max - min) + min; 124 | } 125 | 126 | /** 127 | * Create enum-like object with auto-increment values 128 | * 129 | * @param {Array} values array of keys of enum object 130 | * @param {Boolean} asValue use array item as enum value 131 | * @returns {Object} freezed object 132 | */ 133 | export function createEnum(values, asValue) { 134 | let res = {}; 135 | for (let i = 0; i < values.length; i++) { 136 | const value = asValue ? values[i] : i; // use array item or idx as value 137 | res[values[i]] = value; // add property 138 | } 139 | Object.freeze(res); // make immutable 140 | return res; 141 | } 142 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | module.exports = { 3 | assetsDir: "src/assets/", 4 | // sass-node config with vue-cli-4 5 | // https://stackoverflow.com/a/59537228/10922608 6 | css: { 7 | loaderOptions: { 8 | sass: { 9 | prependData: `@import "@/assets/scss/global.scss";` 10 | } 11 | } 12 | }, 13 | configureWebpack: { 14 | devtool: "source-map" 15 | }, 16 | chainWebpack: config => { 17 | config.module 18 | .rule("sass") 19 | .test(/\.sass$/) 20 | .use("vue-loader") 21 | .loader("sass-loader") 22 | .end(); 23 | 24 | config.resolve.alias.set("@scss", path.resolve(__dirname, "src/assets/scss")); 25 | } 26 | }; 27 | --------------------------------------------------------------------------------