├── .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 |
12 | We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
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 |
2 |
23 |
24 |
25 |
214 |
215 |
285 |
--------------------------------------------------------------------------------
/src/components/Synthesizer.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
249 |
250 |
527 |
--------------------------------------------------------------------------------
/src/components/controllers/BaseController.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
{{ props.label }}
7 |
8 |
9 |
10 |
23 |
24 |
79 |
--------------------------------------------------------------------------------
/src/components/controllers/Controller.vue:
--------------------------------------------------------------------------------
1 |
63 |
--------------------------------------------------------------------------------
/src/components/controllers/DisplayPicker.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ displayValue }}
7 |
8 |
9 |
10 |
11 |
12 |
13 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/src/components/controllers/Fader.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
19 |
20 |
21 |
22 |
51 |
52 |
194 |
--------------------------------------------------------------------------------
/src/components/controllers/Knob.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
110 |
111 |
173 |
--------------------------------------------------------------------------------
/src/components/controllers/Toggle.vue:
--------------------------------------------------------------------------------
1 |
37 |
38 |
80 |
--------------------------------------------------------------------------------
/src/components/controllers/VSelect.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{
6 | item | uppercase
7 | }}
8 |
9 |
10 |
11 |
12 |
13 |
44 |
45 |
88 |
--------------------------------------------------------------------------------
/src/components/controllers/WavePicker.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
13 |
14 |
15 |
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 |
2 |
11 |
12 |
13 |
20 |
21 |
56 |
--------------------------------------------------------------------------------
/src/components/display/LedDisplay.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ text }}
6 |
7 |
8 |
9 |
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 |
2 |
3 |
4 |
5 |
6 | Source
7 | Destination
8 | Amt
9 |
10 |
11 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
68 |
69 |
108 |
--------------------------------------------------------------------------------
/src/components/matrix/MatrixRow.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ idx + 1 }}.
4 |
5 |
10 | {{ item.name }}
11 |
12 |
13 |
14 |
19 | {{
20 | item.name
21 | }}
22 |
23 |
24 |
25 |
34 |
35 |
36 |
37 |
38 |
73 |
74 |
120 |
--------------------------------------------------------------------------------
/src/components/matrix/MatrixRowAmount.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ $_displayValue }}
4 |
5 |
6 |
7 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/components/ui/VSection.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ props.label }}
6 |
7 |
8 |
9 |
19 |
20 |
21 |
22 |
23 |
24 |
31 |
32 |
70 |
--------------------------------------------------------------------------------
/src/components/vis/Analyser.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
96 |
97 |
123 |
--------------------------------------------------------------------------------
/src/components/vis/MainScreen.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | 🛆
5 |
6 |
7 |
8 |
9 | {{
10 | preset.name
11 | }}
12 |
13 |
{{ control }}
14 |
15 |
16 |
17 | 🛆
18 |
19 |
20 |
21 |
22 |
62 |
63 |
165 |
--------------------------------------------------------------------------------
/src/components/vis/Meter.vue:
--------------------------------------------------------------------------------
1 |
2 |
10 |
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 |
--------------------------------------------------------------------------------