├── .eslintrc.js
├── README.md
├── app
├── .eslintrc.js
├── .gitignore
├── LICENSE
├── css
│ └── index.scss
├── images
│ ├── sprites.png
│ └── sprites.svg
├── index.html
├── js
│ ├── analyser.js
│ ├── debounce.js
│ ├── file.js
│ ├── history.js
│ ├── index.js
│ ├── mainctrl.js
│ ├── player.js
│ ├── shims.js
│ ├── storage.js
│ ├── synthfactory.js
│ ├── ui.js
│ └── waveshape.js
├── package.json
├── webpack.config.js
└── yarn.lock
└── lib
├── .gitignore
├── LICENSE
├── README.md
├── index.js
├── package.json
├── src
├── clip.js
├── math.js
├── presets.js
├── random.js
├── sound.js
└── synth.js
├── webpack.config.js
└── yarn.lock
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: 'eslint:recommended',
3 | parserOptions: {
4 | ecmaVersion: '2015',
5 | sourceType: 'module',
6 | },
7 | env: {
8 | // This is needed to access typed arrays (Float32Array and such). These
9 | // have much broader support than just ES2015, but eslint doesn't know
10 | // that.
11 | es6: true,
12 | },
13 | rules: {
14 | // Allow unused function arguments if they match this pattern. This is so
15 | // that we can clearly state that the function receives this argument but
16 | // chooses to ignore it.
17 | 'no-unused-vars': [
18 | 'error',
19 | {
20 | argsIgnorePattern: '^unused_',
21 | },
22 | ],
23 | },
24 | };
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Jfxr is a browser-based tool to generate sound effects, for example for use in
2 | games. It was inspired by [bfxr](http://www.bfxr.net/), but aims to be more
3 | powerful and more intuitive to use.
4 |
5 | **Start using it right now at
6 | [jfxr.frozenfractal.com](https://jfxr.frozenfractal.com).**
7 |
8 | FAQ
9 | ---
10 |
11 | ### Can I use these sounds commercially?
12 |
13 | Yes! Any sound you create with jfxr is entirely yours, and you are free to use
14 | it in any way you like, including commercial projects.
15 |
16 | ### Is attribution required?
17 |
18 | Attribution is not required, but I would really appreciate if you could link
19 | back to jfxr in some way. I would also be delighted if you send me a link to
20 | your creation!
21 |
22 | ### How does it compare to sfxr/bfxr?
23 |
24 | Compared to [bfxr](http://www.bfxr.net/), the only missing feature is the mixer
25 | (which mixes multiple generated sounds together). There is [an open
26 | issue](https://github.com/ttencate/jfxr/issues/11) to address that. Some
27 | filters also have a slightly different meaning, most notably the bit crunch,
28 | which is a real bit crunch rather than a downsample.
29 |
30 | ### What are the system requirements?
31 |
32 | Jfxr has been tested on the latest Chrome and Firefox, on Linux and OS X. In
33 | other modern browsers, I guarantee that the sliders will look broken, but
34 | hopefully everything else will still work.
35 |
36 | Reporting bugs
37 | --------------
38 |
39 | Please report any issues you find to the [issue tracker on
40 | GitHub](https://github.com/ttencate/jfxr/issues).
41 |
42 | Technical details
43 | -----------------
44 |
45 | Jfxr uses [Angular.js](https://angularjs.org/) for its UI and module dependency
46 | management. It relies on several modern web technologies: WebAudio, canvas2d,
47 | local storage and of course CSS3.
48 |
49 | Developing
50 | ----------
51 |
52 | To assemble the JavaScript files into a runnable whole, you need Node.js
53 | and Yarn installed. (npm might work, but is not recommended.)
54 |
55 | To install the development dependencies, run:
56 |
57 | cd app
58 | yarn install
59 |
60 | Then, to build the app:
61 |
62 | yarn build
63 |
64 | This produces output in the `app/dist` directory, which can be used locally or
65 | copied to a webserver.
66 |
67 | Use as a library
68 | ----------------
69 |
70 | The sound synthesis code can be used as a standalone library. To build it
71 | separate from the app:
72 |
73 | cd lib
74 | npm install
75 | npm run build
76 |
77 | This produces an npm package in the `lib/dist` directory, which can be used
78 | as-is or published to the npm registry.
79 |
80 | For development, there is also a script to continuously rebuild on change:
81 |
82 | npm run watch
83 |
84 | For further details, see [`lib/README.md`](lib/README.md) or the [documentation
85 | on npmjs.com](https://www.npmjs.com/package/jfxr).
86 |
87 | Ports
88 | -----
89 |
90 | - [Aurel300](https://github.com/Aurel300) ported the sound generation core to
91 | Rust: [jfxr-rs](https://github.com/Aurel300/jfxr-rs).
92 |
93 | License
94 | -------
95 |
96 | The code itself is under a three-clause BSD license; see LICENSE for details.
97 |
98 | Any sound effects you make are entirely yours to do with as you please, without
99 | any restrictions whatsoever.
100 |
--------------------------------------------------------------------------------
/app/.eslintrc.js:
--------------------------------------------------------------------------------
1 | // Most of the config is inherited from the top-level directory. These are only
2 | // overrides specific to the app.
3 | module.exports = {
4 | env: {
5 | browser: true,
6 | },
7 | };
8 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | dist/
2 | node_modules/
3 | package-lock.json
4 |
--------------------------------------------------------------------------------
/app/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2014, Thomas ten Cate
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions are met:
6 |
7 | 1. Redistributions of source code must retain the above copyright notice, this
8 | list of conditions and the following disclaimer.
9 |
10 | 2. Redistributions in binary form must reproduce the above copyright notice,
11 | this list of conditions and the following disclaimer in the documentation
12 | and/or other materials provided with the distribution.
13 |
14 | 3. Neither the name of the copyright holder nor the names of its contributors
15 | may be used to endorse or promote products derived from this software
16 | without specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 |
--------------------------------------------------------------------------------
/app/css/index.scss:
--------------------------------------------------------------------------------
1 | * {
2 | margin: 0;
3 | padding: 0;
4 | }
5 |
6 | html {
7 | font-family: 'Roboto Condensed', sans-serif;
8 | font-size: 12px;
9 | font-weight: 300;
10 | color: #ccc;
11 | background-color: #101010;
12 | background-image: linear-gradient(#101010 0, #101010 8px, #0c0c0c 8px, #0c0c0c 16px);
13 | background-size: 100% 16px;
14 | background-position: 0 -4px;
15 | height: 100%;
16 | }
17 |
18 | body {
19 | padding: 16px;
20 | box-sizing: border-box;
21 | height: 100%;
22 | }
23 |
24 | input, button {
25 | outline: 0;
26 | }
27 |
28 | input[type="text"], input:not([type]) {
29 | border: 1px solid transparent;
30 | background-color: transparent;
31 | color: inherit;
32 | font: inherit;
33 | }
34 |
35 | input[type="text"]:not(:disabled):not(:focus):hover,
36 | input:not([type]):not(:disabled):not(:focus):hover,
37 | {
38 | border-color: #444;
39 | }
40 |
41 | input[type="text"]:focus,
42 | input:not([type]):focus,
43 | {
44 | color: #ccc;
45 | border-color: #666;
46 | background-color: #111;
47 | box-shadow:
48 | -1px -1px 0.5px rgba(255, 255, 255, 0.1) inset,
49 | 1px 1px 2px rgba(0, 0, 0, 1.0) inset;
50 | }
51 |
52 | input[type="text"].ng-invalid,
53 | input:not([type]).ng-invalid,
54 | {
55 | border-color: #c44;
56 | }
57 |
58 | input[type="submit"] {
59 | width: 100%;
60 | cursor: pointer;
61 | border: 0;
62 | outline: 0;
63 | }
64 |
65 | ul, ol, li {
66 | list-style-type: none;
67 | }
68 |
69 | strong {
70 | font-weight: normal;
71 | color: #eee;
72 | }
73 |
74 | .errorbar {
75 | position: relative;
76 | width: 100%;
77 | box-sizing: border-box;
78 | padding: 8px;
79 | font-size: 16px;
80 | line-height: 24px;
81 | z-index: 1000;
82 | margin-bottom: 16px;
83 | border-radius: 16px;
84 | text-align: center;
85 | }
86 |
87 | .errorbar-panic {
88 | background: #f44;
89 | color: #fff;
90 | }
91 |
92 | .errorbar-warning {
93 | background: #db4;
94 | color: #333;
95 | }
96 |
97 | .github {
98 | position: absolute;
99 | display: block;
100 | top: 0;
101 | right: 0;
102 | width: 149px;
103 | height: 149px;
104 | background-image: url(https://camo.githubusercontent.com/38ef81f8aca64bb9a64448d0d70f1308ef5341ab/68747470733a2f2f73332e616d617a6f6e6177732e636f6d2f6769746875622f726962626f6e732f666f726b6d655f72696768745f6461726b626c75655f3132313632312e706e67);
105 | text-indent: -9999px;
106 | opacity: 0.3;
107 | background-position: 10px -10px;
108 | background-repeat: no-repeat;
109 | transition: background-position 0.05s linear, opacity 0.05s linear;
110 | }
111 |
112 | .github:hover {
113 | opacity: 0.8;
114 | background-position: 0 0;
115 | z-index: 1;
116 | transition: background-position 0.2 linear, opacity 0.2 linear;
117 | }
118 |
119 | .github:active {
120 | opacity: 1.0;
121 | }
122 |
123 | .main {
124 | display: -webkit-flex;
125 | display: flex;
126 | -webkit-flex-direction: column;
127 | flex-direction: column;
128 | padding: 8px;
129 | position: relative;
130 | box-sizing: border-box;
131 | width: 960px;
132 | height: 100%;
133 | min-height: 320px;
134 | margin: 0 auto;
135 | border-radius: 16px;
136 | background: #141218;
137 | box-shadow:
138 | 0 0 8px #000,
139 | 2px 4px 8px #000,
140 | 1px 2px 4px rgba(255, 255, 255, 0.05) inset,
141 | -1px -2px 4px rgba(0, 0, 0, 0.1) inset;
142 | }
143 |
144 | .topbar {
145 | display: -webkit-flex;
146 | width: 100%;
147 | display: flex;
148 | -webkit-flex-direction: row;
149 | flex-direction: row;
150 | height: 47px;
151 | margin-bottom: 8px;
152 | -webkit-flex-grow: 0;
153 | flex-grow: 0;
154 | -webkit-flex-shrink: 0;
155 | flex-shrink: 0;
156 | }
157 |
158 | .content {
159 | display: -webkit-flex;
160 | display: flex;
161 | -webkit-flex-direction: row;
162 | flex-direction: row;
163 | -webkit-flex-grow: 1;
164 | flex-grow: 1;
165 | }
166 |
167 | .pane {
168 | position: relative;
169 | background: #040404;
170 | box-shadow:
171 | 0.5px 1px 1px rgba(0, 0, 0, 1.0) inset,
172 | -0.5px -1px 1px rgba(255, 255, 255, 0.2) inset,
173 | 0 0 2px rgba(0, 0, 0, 1.0);
174 | padding: 8px;
175 | box-sizing: border-box;
176 | }
177 |
178 | .pane-top-right {
179 | border-top-right-radius: 8px;
180 | border-bottom-right-radius: 8px;
181 | }
182 |
183 | .pane-bottom-left {
184 | border-bottom-left-radius: 8px;
185 | }
186 |
187 | .pane-bottom-right {
188 | border-bottom-right-radius: 8px;
189 | }
190 |
191 | .vertical-scroll {
192 | overflow-y: auto;
193 | }
194 |
195 | .column-left {
196 | width: 160px;
197 | margin-right: 8px;
198 | }
199 |
200 | .column-right {
201 | -webkit-flex-grow: 1;
202 | flex-grow: 1;
203 | }
204 |
205 | .titlepane {
206 | margin-left: -8px;
207 | margin-top: -8px;
208 | padding-left: 8px;
209 | padding-top: 8px;
210 | background: #000;
211 | border-top-left-radius: 16px;
212 | box-shadow:
213 | 1px 2px 4px rgba(255, 255, 255, 0.2) inset,
214 | -1px -2px 4px rgba(255, 255, 255, 0.05) inset,
215 | 0.5px 1px 0.5px rgba(255, 255, 255, 0.3) inset,
216 | -0.5px -1px 2px rgba(0, 0, 0, 1.0) inset,
217 | -0.5px -1px 0.5px rgba(255, 255, 255, 0.0) inset,
218 | 0 0 2px rgba(0, 0, 0, 1.0);
219 | }
220 |
221 | h1 {
222 | color: #fff;
223 | font-family: Chango, sans-serif;
224 | text-shadow: 0 0 4px rgba(255, 255, 255, 0.5);
225 | text-transform: uppercase;
226 | padding-left: 6px;
227 | font-size: 32px;
228 | line-height: 39px;
229 | }
230 |
231 | h1 a {
232 | color: inherit;
233 | text-decoration: inherit;
234 | }
235 |
236 | .credits {
237 | font-family: Chango, sans-serif;
238 | font-size: 9px;
239 | line-height: 9px;
240 | margin: -4px 4px 0;
241 | text-align: right;
242 | color: #444;
243 | }
244 |
245 | .credits a {
246 | color: inherit;
247 | text-decoration: none;
248 | transition: color 0.1s linear;
249 | }
250 |
251 | .credits a:hover {
252 | text-decoration: underline;
253 | color: #666;
254 | transition: none;
255 | }
256 |
257 | .credits a:active {
258 | color: #555;
259 | }
260 |
261 | .playbackpane {
262 | display: -webkit-flex;
263 | display: flex;
264 | -webkit-align-items: stretch;
265 | align-items: stretch;
266 | padding: 2px;
267 | }
268 |
269 | .analyser {
270 | -webkit-flex-grow: 1;
271 | flex-grow: 1;
272 | cursor: pointer;
273 | }
274 |
275 | .analyser-disabled {
276 | background-image: linear-gradient(#080808 0%, #101010 30%, #080808 70%, #000000 100%);
277 | box-shadow:
278 | 0 0 2px rgba(255, 255, 255, 0.1) inset;
279 | }
280 |
281 | .shiny {
282 | background-image: linear-gradient(#181830 0%, #5050a0 100%);
283 | box-shadow:
284 | 0 0 2px rgba(224, 224, 255, 0.3) inset,
285 | 0 0 8px rgba(0, 0, 0, 0.8) inset,
286 | 0 8px 4px rgba(224, 224, 255, 0.6) inset;
287 | color: rgba(255, 255, 255, 0.7);
288 | text-shadow:
289 | -1px -2px 4px rgba(0, 0, 0, 0.5),
290 | 1px 2px 4px rgba(128, 128, 255, 0.5);
291 | overflow: hidden;
292 | transition: all 0.03s linear;
293 | }
294 |
295 | .shiny:hover {
296 | color: rgba(240, 240, 255, 1.0);
297 | text-shadow:
298 | -1px -2px 4px rgba(0, 0, 0, 0.5),
299 | 1px 2px 4px rgba(128, 128, 255, 1.0);
300 | }
301 |
302 | .shiny-display {
303 | font-family: Chango, sans-serif;
304 | font-size: 24px;
305 | height: 32px;
306 | border-radius: 8px;
307 | }
308 |
309 | .shiny-checked {
310 | background-image: linear-gradient(#181830 0%, #303060 100%);
311 | color: rgba(255, 255, 255, 0.9);
312 | box-shadow:
313 | -1px -1px 0px rgba(224, 224, 255, 0.2) inset,
314 | 1px 1px 8px rgba(0, 0, 0, 1.0) inset,
315 | 0 6px 4px rgba(192, 192, 255, 0.4) inset;
316 | }
317 |
318 | .shiny:active {
319 | color: rgba(255, 255, 255, 0.6);
320 | box-shadow:
321 | -1px -1px 0px rgba(224, 224, 255, 0.2) inset,
322 | 1px 1px 8px rgba(0, 0, 0, 1.0) inset,
323 | 0 4px 4px rgba(192, 192, 255, 0.4) inset;
324 | }
325 |
326 | .shinycontent {
327 | position: relative;
328 | }
329 |
330 | .shiny-checked .shinycontent,
331 | .shiny:active .shinycontent {
332 | left: 1px;
333 | top: 1px;
334 | }
335 |
336 | .autoplay {
337 | position: relative;
338 | cursor: pointer;
339 | box-sizing: border-box;
340 | width: 16px;
341 | padding: 0 8px;
342 | border: none;
343 | font-family: 'Roboto Condensed', sans-serif;
344 | font-size: 11px;
345 | font-weight: 400;
346 | text-transform: uppercase;
347 | line-height: 43px;
348 | }
349 |
350 | .autoplay input {
351 | position: absolute;
352 | visibility: hidden;
353 | }
354 |
355 | .autoplay .shinycontent {
356 | position: relative;
357 | display: block;
358 | margin-left: -101px;
359 | width: 200px;
360 | text-align: center;
361 | -webkit-transform: rotate(-90deg);
362 | transform: rotate(-90deg);
363 | -webkit-transform-origin: 50% 50%;
364 | transform-origin: 50% 50%;
365 | }
366 |
367 | .playstop {
368 | cursor: pointer;
369 | width: 76px;
370 | border: none;
371 | border-top-right-radius: 8px;
372 | border-bottom-right-radius: 8px;
373 | font-size: 32px;
374 | line-height: 32px;
375 | }
376 |
377 | .playicon {
378 | display: inline-block;
379 | vertical-align: center;
380 | width: 0;
381 | height: 0;
382 | border-style: solid;
383 | border-width: 12px 21px;
384 | border-color: transparent;
385 | border-left-color: #ddd;
386 | position: relative;
387 | left: 10.5px;
388 | }
389 |
390 | .shiny:hover .playicon {
391 | border-left-color: rgba(240, 240, 255, 1.0);
392 | }
393 |
394 | .filespane {
395 | display: -webkit-flex;
396 | display: flex;
397 | -webkit-flex-direction: column;
398 | flex-direction: column;
399 | -webkit-flex-shrink: 0;
400 | flex-shrink: 0;
401 | }
402 |
403 | .button {
404 | display: block;
405 | width: 100%;
406 | height: 16px;
407 | border: 0;
408 | cursor: pointer;
409 |
410 | font-family: inherit;
411 | font-size: 12px;
412 | line-height: 16px;
413 | font-weight: 400;
414 | text-align: center;
415 |
416 | background-color: #111;
417 | color: #888;
418 | box-shadow:
419 | 2px 2px 4px rgba(255, 255, 255, 0.05) inset,
420 | -2px -2px 4px rgba(0, 0, 0, 0.4) inset;
421 |
422 | transition: color 0.1s linear, background-color 0.1s linear;
423 |
424 | -webkit-flex-grow: 1;
425 | flex-grow: 1;
426 | }
427 |
428 | .button-checked {
429 | background-color: #222;
430 | color: #aaa;
431 | box-shadow:
432 | -2px -2px 4px rgba(255, 255, 255, 0.05) inset,
433 | 2px 2px 4px rgba(0, 0, 0, 0.4) inset;
434 | text-shadow: 0 0 1px rgba(255, 255, 255, 0.3);
435 | }
436 |
437 | .button:hover:not(:disabled) {
438 | color: #ccc;
439 | background-color: #222;
440 | transition: none;
441 | }
442 |
443 | .button:active:not(:disabled) {
444 | background-color: #181818;
445 | box-shadow:
446 | -1px -1px 2px rgba(255, 255, 255, 0.05) inset,
447 | 1px 1px 2px rgba(0, 0, 0, 0.4) inset;
448 | }
449 |
450 | .button:active:not(:disabled) > span,
451 | .button-checked > span {
452 | position: relative;
453 | top: 1px;
454 | left: 1px;
455 | }
456 |
457 | .button:disabled {
458 | color: #444;
459 | box-shadow: none;
460 | cursor: inherit;
461 | }
462 |
463 | .button-tool {
464 | -webkit-flex-basis: 0;
465 | flex-basis: 0;
466 | -webkit-flex-grow: 1;
467 | flex-grow: 1;
468 | }
469 |
470 | .offsetparent {
471 | position: relative;
472 | }
473 |
474 | .toolbar {
475 | position: relative;
476 | flex-grow: 0;
477 | flex-shrink: 0;
478 | display: -webkit-flex;
479 | display: flex;
480 | -webkit-flex-direction: row;
481 | flex-direction: row;
482 | margin-bottom: 8px;
483 | }
484 |
485 | .linkbox {
486 | position: absolute;
487 | top: 16px;
488 | left: 0;
489 | width: 128px;
490 | z-index: 10;
491 | }
492 |
493 | .button-createnew {
494 | position: absolute;
495 | left: 0;
496 | top: 0;
497 | width: 16px;
498 | height: 100%;
499 | display: -webkit-flex;
500 | display: flex;
501 | -webkit-align-items: center;
502 | align-items: center;
503 | -webkit-justify-content: center;
504 | justify-content: center;
505 | overflow: hidden;
506 | }
507 |
508 | .button-createnew input {
509 | position: absolute;
510 | visibility: hidden;
511 | }
512 |
513 | .button-createnew > span {
514 | display: block;
515 | white-space: nowrap;
516 | width: 200px;
517 | margin: 0 -99px 0 -101px;
518 | -webkit-transform: rotate(-90deg);
519 | transform: rotate(-90deg);
520 | -webkit-transform-origin: 50% 50%;
521 | transform-origin: 50% 50%;
522 | }
523 |
524 | .presets {
525 | padding-left: 16px;
526 | width: 100%;
527 | }
528 |
529 | .button-preset {
530 | width: 100%;
531 | }
532 |
533 | .history {
534 | -webkit-flex: 1;
535 | flex: 1;
536 | align-items: stretch;
537 | overflow: auto;
538 | }
539 |
540 | .sound {
541 | position: relative;
542 | transition: color 0.1s linear, background-color 0.1s linear;
543 | }
544 |
545 | .sound:hover {
546 | color: #eee;
547 | transition: none;
548 | }
549 |
550 | .sound:active {
551 | color: #ccc;
552 | }
553 |
554 | .sound-current {
555 | background: #222;
556 | color: #eee;
557 | }
558 |
559 | .soundname {
560 | box-sizing: border-box;
561 | width: 100%;
562 | height: 16px;
563 | }
564 |
565 | .soundnamesensor {
566 | position: absolute;
567 | top: 0;
568 | left: 0;
569 | width: 100%;
570 | height: 100%;
571 | cursor: pointer;
572 | }
573 |
574 | .iconbutton {
575 | display: block;
576 | width: 15px;
577 | height: 15px;
578 | background-image: url(../images/sprites.png);
579 | border: 0;
580 | background-color: transparent;
581 | opacity: 0.4;
582 | transition: opacity 0.1s linear;
583 | }
584 |
585 | .iconbutton:not(:disabled) {
586 | cursor: pointer;
587 | }
588 |
589 | .iconbutton:not(:disabled):hover {
590 | opacity: 0.7;
591 | transition: none;
592 | }
593 |
594 | .iconbutton:not(:disabled):active {
595 | opacity: 0.6;
596 | }
597 |
598 | .iconbutton:disabled {
599 | opacity: 0.1;
600 | }
601 |
602 | .iconbutton-delete {
603 | background-position: 0px -15px;
604 | }
605 |
606 | .deletebutton {
607 | display: none;
608 | position: absolute;
609 | right: 0;
610 | top: 0;
611 | }
612 |
613 | .sound:hover .deletebutton {
614 | display: block;
615 | }
616 |
617 | .statusbarpane {
618 | padding-bottom: 0;
619 | background-image: linear-gradient(to top, #0b0b0b 16px, transparent 16px);
620 | }
621 |
622 | .statusbar {
623 | margin-top: 8px;
624 | height: 16px;
625 | line-height: 16px;
626 | color: #444;
627 | font-size: 11px;
628 | }
629 |
630 | .statusbar-right {
631 | text-align: right;
632 | }
633 |
634 | .statusbar a {
635 | color: #555;
636 | text-decoration: none;
637 | transition: color 0.1s linear;
638 | }
639 |
640 | .statusbar a:hover {
641 | color: #aaa;
642 | text-decoration: underline;
643 | }
644 |
645 | .statusbar a:active {
646 | color: #888;
647 | text-decoration: underline;
648 | }
649 |
650 | .mainpane {
651 | display: -webkit-flex;
652 | display: flex;
653 | -webkit-flex-direction: column;
654 | flex-direction: column;
655 | }
656 |
657 | .canvas {
658 | display: block;
659 | box-sizing: border-box;
660 | width: 100%;
661 | background-color: #111;
662 | box-shadow:
663 | 0.5px 1px 0.5px rgba(255, 255, 255, 0.2),
664 | 1px 2px 2px rgba(0, 0, 0, 1.0);
665 | }
666 |
667 | .canvas-waveshape {
668 | height: 63px;
669 | }
670 |
671 | .canvas-small {
672 | height: 23px;
673 | margin-top: 4px;
674 | margin-bottom: 5px;
675 | }
676 |
677 | .parameters {
678 | display: -webkit-flex;
679 | display: flex;
680 | margin-top: 8px;
681 | -webkit-flex-grow: 1;
682 | flex-grow: 1;
683 | -webkit-flex-shrink: 1;
684 | flex-shrink: 1;
685 | -webkit-flex-basis: 0;
686 | flex-basis: 0;
687 | }
688 |
689 | .parameters-column {
690 | -webkit-flex-grow: 1;
691 | flex-grow: 1;
692 | -webkit-flex-basis: 0;
693 | flex-basis: 0;
694 | padding-right: 16px;
695 | border-right: 1px solid #080808;
696 | margin-right: 16px;
697 | }
698 |
699 | .parameters-column:last-child {
700 | padding-right: 0;
701 | border-right: 0;
702 | margin-right: 0;
703 | }
704 |
705 | h2 {
706 | text-transform: uppercase;
707 | font-weight: 400;
708 | font-size: 100%;
709 | padding-top: 16px;
710 | line-height: 16px;
711 | box-sizing: border-box;
712 | color: #555;
713 | text-shadow: 0 0 1px #333;
714 | border-bottom: 1px solid #111;
715 | }
716 |
717 | h2:first-child {
718 | padding-top: 0;
719 | }
720 |
721 | .amplitude {
722 | color: #d66;
723 | }
724 |
725 | .pitch {
726 | color: #bb5;
727 | }
728 |
729 | .harmonics {
730 | color: #5b5;
731 | }
732 |
733 | .tone {
734 | color: #b6b;
735 | }
736 |
737 | .filters {
738 | color: #5ba;
739 | }
740 |
741 | .output {
742 | color: #57d;
743 | }
744 |
745 | .param {
746 | box-sizing: border-box;
747 | height: 16px;
748 | border-bottom: 1px solid #080808;
749 | display: -webkit-flex;
750 | display: flex;
751 | -webkit-flex-direction: row;
752 | flex-direction: row;
753 | -webkit-align-items: center;
754 | align-items: center;
755 | }
756 |
757 | .param-disabled {
758 | opacity: 0.5;
759 | }
760 |
761 | .paramlabel {
762 | box-sizing: border-box;
763 | width: 112px;
764 | text-align: right;
765 | padding-right: 8px;
766 | }
767 |
768 | .paramcontent {
769 | display: -webkit-flex;
770 | display: flex;
771 | -webkit-flex-direction: row;
772 | flex-direction: row;
773 | -webkit-flex-grow: 1;
774 | flex-grow: 1;
775 | height: 16px;
776 | padding-right: 8px;
777 | }
778 |
779 | .parambuttons {
780 | display: -webkit-flex;
781 | display: flex;
782 | -webkit-flex-direction: row;
783 | flex-direction: row;
784 | }
785 |
786 | .iconbutton-lock {
787 | background-position: -30px -15px;
788 | }
789 |
790 | .iconbutton-lock-locked {
791 | background-position: -15px -15px;
792 | background-color: #555;
793 | }
794 |
795 | .iconbutton-reset {
796 | background-position: -45px -15px;
797 | }
798 |
799 | .paramcontrol {
800 | box-sizing: border-box;
801 | -webkit-flex-grow: 1;
802 | flex-grow: 1;
803 | padding-right: 8px;
804 | }
805 |
806 | .paramvalue {
807 | box-sizing: border-box;
808 | width: 41px;
809 | text-align: right;
810 | }
811 |
812 | .paramunit {
813 | box-sizing: border-box;
814 | width: 16px;
815 | text-align: left;
816 | padding-left: 3px;
817 | }
818 |
819 | .customparamvalue {
820 | box-sizing: border-box;
821 | width: 57px; /* paramvalue + paramunit */
822 | text-align: right;
823 | }
824 |
825 | .waveforms {
826 | display: -webkit-flex;
827 | display: flex;
828 | -webkit-justify-content: space-between;
829 | justify-content: space-between;
830 | }
831 |
832 | .waveform {
833 | cursor: pointer;
834 | text-indent: -9999px;
835 | display: inline-block;
836 | width: 15px;
837 | height: 15px;
838 | background: url(../images/sprites.png);
839 | opacity: 0.7;
840 | transition: opacity 0.2s linear;
841 | }
842 |
843 | .waveform:hover {
844 | opacity: 1.0;
845 | transition: none;
846 | }
847 |
848 | .waveform.checked {
849 | background-color: #444;
850 | }
851 |
852 | .waveform-sine {
853 | background-position: 0 0;
854 | }
855 |
856 | .waveform-triangle {
857 | background-position: -15px 0;
858 | }
859 |
860 | .waveform-sawtooth {
861 | background-position: -30px 0;
862 | }
863 |
864 | .waveform-square {
865 | background-position: -45px 0;
866 | }
867 |
868 | .waveform-tangent {
869 | background-position: -60px 0;
870 | }
871 |
872 | .waveform-whistle {
873 | background-position: -75px 0;
874 | }
875 |
876 | .waveform-breaker {
877 | background-position: -90px 0;
878 | }
879 |
880 | .waveform-whitenoise {
881 | background-position: -105px 0;
882 | }
883 |
884 | .waveform-pinknoise {
885 | background-position: -120px 0;
886 | }
887 |
888 | .waveform-brownnoise {
889 | background-position: -135px 0;
890 | }
891 |
892 | .floatslider {
893 | display: block;
894 | -webkit-appearance: none;
895 | background-color: transparent;
896 | background-image: linear-gradient(
897 | rgba(0, 0, 0, 0) 4px,
898 | #000 4px,
899 | #222 8px,
900 | #000 8px,
901 | #000 9px,
902 | rgba(0, 0, 0, 0) 9px
903 | );
904 | width: 100%;
905 | height: 13px;
906 | margin: 1px 0;
907 | }
908 |
909 | .floatslider::-moz-range-track {
910 | display: block;
911 | -moz-appearance: none;
912 | border: 0;
913 | background-color: transparent;
914 | background-image: linear-gradient(
915 | rgba(0, 0, 0, 0) 4px,
916 | #000 4px,
917 | #222 8px,
918 | #000 8px,
919 | #000 9px,
920 | rgba(0, 0, 0, 0) 9px
921 | );
922 | width: 100%;
923 | height: 13px;
924 | margin: 1px 0;
925 | }
926 |
927 | .floatslider:not(:disabled) {
928 | cursor: pointer;
929 | }
930 |
931 | .floatslider::-moz-range-thumb {
932 | -moz-appearance: none;
933 | border: 0;
934 | background-color: #666;
935 | background-image: radial-gradient(circle, rgba(0, 0, 0, 0.3) 0%, transparent 100%);
936 | border-radius: 2px;
937 | width: 13px;
938 | height: 13px;
939 | box-shadow:
940 | 1px 1px 0.5px rgba(255, 255, 255, 0.3) inset,
941 | -1px -1px 0.5px rgba(0, 0, 0, 0.5) inset;
942 | transition: background-color 0.2s linear;
943 | }
944 |
945 | .floatslider::-webkit-slider-thumb {
946 | -webkit-appearance: none;
947 | background-color: #666;
948 | background-image: radial-gradient(circle, rgba(0, 0, 0, 0.3) 0%, transparent 100%);
949 | border-radius: 2px;
950 | width: 13px;
951 | height: 13px;
952 | box-shadow:
953 | 1px 1px 0.5px rgba(255, 255, 255, 0.3) inset,
954 | -1px -1px 0.5px rgba(0, 0, 0, 0.5) inset;
955 | transition: background-color 0.2s linear;
956 | }
957 |
958 | .floatslider::-webkit-slider-thumb:hover {
959 | background-color: #888;
960 | transition: none;
961 | }
962 |
963 | .floatslider::-webkit-slider-thumb:active {
964 | background-color: #555;
965 | box-shadow:
966 | 1px 1px 0.5px rgba(255, 255, 255, 0.3) inset,
967 | -1px -1px 0.5px rgba(0, 0, 0, 0.5) inset;
968 | }
969 |
970 | .floatslider:disabled::-webkit-slider-thumb {
971 | display: none;
972 | }
973 |
974 | .booleanlabel input {
975 | display: none;
976 | }
977 |
978 | .booleanlabel {
979 | display: block;
980 | width: 13px;
981 | height: 13px;
982 | margin: 1px 0;
983 | color: #000;
984 | background-color: #666;
985 | background-image: radial-gradient(circle, rgba(0, 0, 0, 0.3) 0%, transparent 100%), url(../images/sprites.png);
986 | background-position: 0 0, -61px -16px;
987 | border-radius: 2px;
988 | box-shadow:
989 | 1px 1px 0.5px rgba(255, 255, 255, 0.3) inset,
990 | -1px -1px 0.5px rgba(0, 0, 0, 0.5) inset;
991 | cursor: pointer;
992 | transition: background-color 0.2s linear;
993 | }
994 |
995 | .booleanlabel:hover {
996 | background-color: #888;
997 | transition: none;
998 | }
999 |
1000 | .booleanlabel:active {
1001 | background-color: #555;
1002 | box-shadow:
1003 | 1px 1px 0.5px rgba(255, 255, 255, 0.3) inset,
1004 | -1px -1px 0.5px rgba(0, 0, 0, 0.5) inset;
1005 | }
1006 |
1007 | .booleanlabel-checked {
1008 | background-position: 0 0, -76px -16px;
1009 | }
1010 |
1011 | .booleanlabel.booleanlabel-disabled {
1012 | background-color: #666;
1013 | box-shadow:
1014 | 1px 1px 0.5px rgba(255, 255, 255, 0.3) inset,
1015 | -1px -1px 0.5px rgba(0, 0, 0, 0.5) inset;
1016 | cursor: inherit;
1017 | }
1018 |
1019 | .floattext {
1020 | box-sizing: border-box;
1021 | width: 100%;
1022 | text-align: right;
1023 | height: 17px;
1024 | }
1025 |
1026 | .paramdescription {
1027 | margin-top: 8px;
1028 | border-top: 1px solid #080808;
1029 | padding-top: 7px;
1030 | }
1031 |
1032 | [ng\:cloak], [ng-cloak], .ng-cloak {
1033 | display: none !important;
1034 | }
1035 |
--------------------------------------------------------------------------------
/app/images/sprites.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ttencate/jfxr/99ac81e607cb71147038a227c1b146c5f066625b/app/images/sprites.png
--------------------------------------------------------------------------------
/app/images/sprites.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
350 |
--------------------------------------------------------------------------------
/app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | jfxr
5 |
6 |
7 |
8 |
9 |
10 |
11 | Exporting WAV files is broken on Safari. Saving will open a new tab, which you can still save manually.
12 | |
Details
13 | |
Dismiss
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
26 |
27 |
28 |
29 |
33 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
48 |
51 |
54 |
58 |
61 |
62 |
73 |
74 |
80 |
87 |
88 | Rendering…
89 | Render time: {{ctrl.synth.renderTimeMs}} ms
90 |
91 |
92 |
93 |
94 |
95 |
160 |
161 |
162 | {{ctrl.hoveredParam.label}}: {{ctrl.hoveredParam.description}}
163 |
164 |
165 |
166 |
167 |
168 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
--------------------------------------------------------------------------------
/app/js/analyser.js:
--------------------------------------------------------------------------------
1 | export var analyser = [function() {
2 | var draw = function(context, width, height, data) {
3 | var barWidth = Math.max(2, Math.ceil(width / data.length));
4 | var numBars = Math.floor(width / barWidth);
5 | var barGap = 1;
6 |
7 | var blockHeight = 3;
8 | var blockGap = 1;
9 | var numBlocks = Math.floor(height / blockHeight);
10 |
11 | context.clearRect(0, 0, width, height);
12 |
13 | var gradient = context.createLinearGradient(0, 0, 0, height);
14 | gradient.addColorStop(0, '#f00');
15 | gradient.addColorStop(0.6, '#dd0');
16 | gradient.addColorStop(1, '#0b0');
17 |
18 | var i;
19 | var y;
20 |
21 | context.fillStyle = gradient;
22 | context.globalAlpha = 1.0;
23 | for (i = 0; i < numBars; i++) {
24 | var f = (data[i] + 100) / 100;
25 | y = Math.round(f * numBlocks) / numBlocks;
26 | context.fillRect(i * barWidth, (1 - y) * height, barWidth - barGap, y * height);
27 | }
28 |
29 | context.fillStyle = '#111';
30 | context.globalAlpha = 0.3;
31 | for (i = 0; i < numBlocks; i++) {
32 | y = i * blockHeight + 1;
33 | context.fillRect(0, y, width, blockGap);
34 | }
35 | };
36 |
37 | return {
38 | scope: {
39 | 'analyser': '=',
40 | 'enabled': '=',
41 | },
42 | link: function(scope, element, unused_attrs, unused_ctrl) {
43 | var canvas = element[0];
44 | var context = canvas.getContext('2d');
45 | var width = canvas.width;
46 | var height = canvas.height;
47 |
48 | var animFrame = function() {
49 | if (!enabled) {
50 | return;
51 | }
52 | if (data) {
53 | draw(context, width, height, data);
54 | }
55 | window.requestAnimationFrame(animFrame);
56 | };
57 |
58 | var data = null;
59 | scope.$watch('analyser', function(value) {
60 | data = value;
61 | });
62 |
63 | var enabled = true;
64 | scope.$watch('enabled', function(value) {
65 | enabled = value;
66 | if (enabled) {
67 | window.requestAnimationFrame(animFrame);
68 | } else {
69 | context.clearRect(0, 0, width, height);
70 | }
71 | });
72 | },
73 | };
74 | }];
75 |
--------------------------------------------------------------------------------
/app/js/debounce.js:
--------------------------------------------------------------------------------
1 | export function debounce(fn, delay) {
2 | var timeoutId = null;
3 | var finalCallArguments = null;
4 | return function() {
5 | if (timeoutId === null) {
6 | fn.apply(this, arguments);
7 | timeoutId = window.setTimeout(function() {
8 | timeoutId = null;
9 | if (finalCallArguments) {
10 | fn.apply(this, finalCallArguments);
11 | finalCallArguments = null;
12 | }
13 | }.bind(this), delay);
14 | } else {
15 | finalCallArguments = Array.prototype.slice.call(arguments);
16 | }
17 | };
18 | }
19 |
--------------------------------------------------------------------------------
/app/js/file.js:
--------------------------------------------------------------------------------
1 | import angular from 'angular';
2 | import { saveAs } from 'file-saver';
3 |
4 | import { Sound } from '../../lib';
5 |
6 | export var fileStorage = ['$q', function($q) {
7 |
8 | var download = function(blob, filename) {
9 | saveAs(blob, filename);
10 | };
11 |
12 | var uploadFile = function(file) {
13 | var deferred = $q.defer();
14 | var reader = new FileReader();
15 | reader.addEventListener('load', function() {
16 | deferred.resolve({name: file.name, data: reader.result});
17 | });
18 | reader.addEventListener('error', function() {
19 | deferred.reject(reader.error);
20 | });
21 | reader.readAsText(file);
22 | return deferred.promise;
23 | }
24 |
25 | var upload = function() {
26 | var input = document.createElement('input');
27 | input.type = 'file';
28 | input.multiple = true;
29 | var deferred = $q.defer();
30 | angular.element(input).on('change', function() {
31 | var filePromises = [];
32 | for (var i = 0; i < input.files.length; i++) {
33 | var file = input.files[i];
34 | if (file) {
35 | filePromises.push(uploadFile(file));
36 | }
37 | }
38 | $q.all(filePromises).then(function(msgs) {
39 | deferred.resolve(msgs);
40 | }, function(err) {
41 | deferred.reject(err);
42 | });
43 | });
44 | input.focus();
45 | input.click();
46 | // Note: if the file picker dialog is cancelled, we never reject the
47 | // promise so we leak some memory. Detecting cancel is tricky:
48 | // https://stackoverflow.com/questions/4628544/how-to-detect-when-cancel-is-clicked-on-file-input
49 | return deferred.promise;
50 | };
51 |
52 | this.downloadWav = function(clip, basename) {
53 | var blob = new Blob([clip.toWavBytes()], {type: 'audio/wav'});
54 | download(blob, basename + '.wav');
55 | };
56 |
57 | this.saveJfxr = function(sound, basename) {
58 | var json = sound.serialize();
59 | var blob = new Blob([json], {type: 'application/json'});
60 | download(blob, basename + '.jfxr');
61 | };
62 |
63 | this.loadJfxrs = function() {
64 | return upload().then(function(msgs) {
65 | var sounds = [];
66 | for (var i = 0; i < msgs.length; i++) {
67 | var msg = msgs[i];
68 | var sound = new Sound();
69 | try {
70 | sound.parse(msg.data);
71 | } catch (ex) {
72 | console.error('Could not parse sound', ex); // eslint-disable-line no-console
73 | continue;
74 | }
75 | sound.name = msg.name.replace(/\.jfxr$/, '');
76 | sounds.push(sound);
77 | }
78 | return sounds;
79 | });
80 | };
81 | }];
82 |
--------------------------------------------------------------------------------
/app/js/history.js:
--------------------------------------------------------------------------------
1 | import { clamp, Sound } from '../../lib';
2 |
3 | export var history = ['$rootScope', 'localStorage', function($rootScope, localStorage) {
4 | var sounds = [];
5 | var undoStacks = [];
6 | var soundIndex = null;
7 |
8 | this.getSounds = function() {
9 | return sounds;
10 | };
11 |
12 | this.getCurrentIndex = function() {
13 | return soundIndex;
14 | };
15 |
16 | this.getCurrentSound = function() {
17 | if (soundIndex === null) return null;
18 | return sounds[soundIndex];
19 | };
20 |
21 | this.setCurrentIndex = function(index) {
22 | index = index || 0;
23 | if (sounds.length === 0) return;
24 | soundIndex = clamp(0, sounds.length - 1, index);
25 | };
26 |
27 | this.newSound = function(basename) {
28 | var sound = new Sound();
29 | sound.name = getFreeName(basename);
30 | this.addSound(sound);
31 | return sound;
32 | };
33 |
34 | this.addSound = function(sound, index) {
35 | if (index === undefined) index = 0;
36 | sounds.splice(index, 0, sound);
37 | undoStacks.splice(index, 0, []);
38 | soundIndex = index;
39 | };
40 |
41 | this.duplicateSound = function(index) {
42 | var dup = sounds[index].clone();
43 | dup.name = getFreeName(dup.name.replace(/ \d+$/, ''));
44 | this.addSound(dup, index);
45 | };
46 |
47 | this.deleteSound = function(index) {
48 | sounds.splice(index, 1);
49 | undoStacks.splice(index, 1);
50 | if (soundIndex > index) {
51 | soundIndex--;
52 | }
53 | if (soundIndex >= sounds.length) {
54 | soundIndex = sounds.length - 1;
55 | }
56 | if (soundIndex < 0) {
57 | soundIndex = null;
58 | }
59 | };
60 |
61 | this.undo = function() {
62 | if (soundIndex === null) return;
63 | var undoStack = undoStacks[soundIndex];
64 | if (undoStack.length > 0) {
65 | var json = undoStack[undoStack.length - 1];
66 | this.getCurrentSound().parse(json);
67 | // We don't pop, because the change to the current sound triggers a watch
68 | // on the current sound. That watch is responsible for removing the top
69 | // of the stack. If we did it here, the watch would immediately re-add
70 | // the previous (now undone) state on top of the stack.
71 | }
72 | };
73 |
74 | this.canUndo = function() {
75 | return soundIndex !== null && undoStacks[soundIndex].length > 0;
76 | };
77 |
78 | var getFreeName = function(basename) {
79 | var max = 0;
80 | for (var i = 0; i < sounds.length; i++) {
81 | var m = sounds[i].name.match('^' + basename + ' (\\d+)$');
82 | if (m) {
83 | max = Math.max(max, parseInt(m[1]));
84 | }
85 | }
86 | return basename + ' ' + (max + 1);
87 | }.bind(this);
88 |
89 | var storageName = function(index) {
90 | return 'sounds[' + index + ']';
91 | };
92 |
93 | var storeSound = function(index, value) {
94 | if (value === undefined && index < sounds.length) {
95 | value = sounds[index].serialize();
96 | }
97 | if (!value) value = '';
98 | localStorage.set(storageName(index), value);
99 | }.bind(this);
100 |
101 | for (var i = 0;; i++) {
102 | var str = localStorage.get(storageName(i), undefined);
103 | if (!str) {
104 | break;
105 | }
106 | var sound = new Sound();
107 | try {
108 | sound.parse(str);
109 | } catch (ex) {
110 | console.error('Could not parse sound from local storage', ex); // eslint-disable-line no-console
111 | continue;
112 | }
113 | this.addSound(sound, i);
114 | }
115 |
116 | soundIndex = clamp(0, sounds.length - 1, localStorage.get('soundIndex', 0));
117 |
118 | $rootScope.$watchCollection(function() { return this.getSounds(); }.bind(this), function(value, oldValue) {
119 | var i;
120 | // The entire array might have shifted, so we need to save them all.
121 | for (i = 0; i < value.length; i++) {
122 | storeSound(i);
123 | }
124 | for (i = value.length; i < oldValue.length; i++) {
125 | localStorage.delete(storageName(i));
126 | }
127 | }.bind(this));
128 |
129 | var unwatchCurrentSound = null;
130 | $rootScope.$watch(function() { return this.getCurrentSound(); }.bind(this), function(value) {
131 | if (unwatchCurrentSound) {
132 | unwatchCurrentSound();
133 | unwatchCurrentSound = null;
134 | }
135 | if (value) {
136 | unwatchCurrentSound = $rootScope.$watch(
137 | function() { return value.serialize(); }, function(json, prevJson) {
138 | storeSound(soundIndex, json);
139 | if (json != prevJson) {
140 | var undoStack = undoStacks[soundIndex];
141 | if (undoStack.length > 0 && undoStack[undoStack.length - 1] == json) {
142 | // We just undid something.
143 | undoStack.pop();
144 | } else {
145 | undoStacks[soundIndex].push(prevJson);
146 | }
147 | }
148 | });
149 | }
150 | });
151 |
152 | $rootScope.$watch(function() { return this.getCurrentIndex(); }.bind(this), function(value) {
153 | localStorage.set('soundIndex', value);
154 | }.bind(this));
155 | }];
156 |
--------------------------------------------------------------------------------
/app/js/index.js:
--------------------------------------------------------------------------------
1 | import angular from 'angular';
2 |
3 | import { missingBrowserFeatures } from './shims.js';
4 | import { MainCtrl } from './mainctrl.js';
5 | import { analyser } from './analyser.js';
6 | import { fileStorage } from './file.js';
7 | import { history } from './history.js';
8 | import { context, Player } from './player.js';
9 | import { localStorage } from './storage.js';
10 | import { synthFactory } from './synthfactory.js';
11 | import { customParam, floatParam, booleanParam, waveformButton, linkbox } from './ui.js';
12 | import { canvasManager, waveshape, drawAmplitude, drawFrequency } from './waveshape.js';
13 |
14 | import '../css/index.scss';
15 |
16 | var jfxrApp = angular.module('jfxrApp', []);
17 |
18 | jfxrApp.controller('MainCtrl', MainCtrl);
19 |
20 | jfxrApp.directive('analyser', analyser);
21 | jfxrApp.directive('customParam', customParam);
22 | jfxrApp.directive('floatParam', floatParam);
23 | jfxrApp.directive('booleanParam', booleanParam);
24 | jfxrApp.directive('waveformButton', waveformButton);
25 | jfxrApp.directive('linkbox', linkbox);
26 | jfxrApp.directive('canvasManager', canvasManager);
27 | jfxrApp.directive('waveshape', waveshape);
28 | jfxrApp.directive('drawAmplitude', drawAmplitude);
29 | jfxrApp.directive('drawFrequency', drawFrequency);
30 |
31 | jfxrApp.service('context', context);
32 | jfxrApp.service('fileStorage', fileStorage);
33 | jfxrApp.service('history', history);
34 | jfxrApp.service('Player', Player);
35 | jfxrApp.service('localStorage', localStorage);
36 | jfxrApp.service('synthFactory', synthFactory);
37 |
38 | function init() {
39 | var panic = angular.element(document.getElementById('panic'));
40 | var missing = missingBrowserFeatures();
41 | if (missing.length > 0) {
42 | panic.html(
43 | 'Unfortunately, jfxr cannot run in this browser because it lacks the following features: ' +
44 | missing.join(', ') + '. Try a recent Chrome or Firefox instead.');
45 | return;
46 | }
47 | panic.remove();
48 |
49 | angular.element(document).ready(function() {
50 | angular.bootstrap(document, ['jfxrApp']);
51 | });
52 | }
53 |
54 | init();
55 |
--------------------------------------------------------------------------------
/app/js/mainctrl.js:
--------------------------------------------------------------------------------
1 | import angular from 'angular';
2 |
3 | import { Sound, Preset, ALL_PRESETS } from '../../lib';
4 | import { debounce } from './debounce.js';
5 | import { callIfSaveAsBroken } from './shims.js';
6 |
7 | export var MainCtrl = ['context', 'Player', '$scope', '$timeout', '$window', 'localStorage', 'fileStorage', 'history', 'synthFactory', function(
8 | context, Player, $scope, $timeout, $window, localStorage, fileStorage, history, synthFactory) {
9 | this.showSafariWarning = false;
10 | callIfSaveAsBroken(function() { this.showSafariWarning = true; }.bind(this));
11 |
12 | var player = new Player();
13 |
14 | this.buffer = null;
15 | this.synth = null;
16 |
17 | this.history = history;
18 |
19 | this.analyserEnabled = localStorage.get('analyserEnabled', true);
20 | this.autoplay = localStorage.get('autoplayEnabled', true);
21 | this.createNew = localStorage.get('createNew', true);
22 |
23 | this.presets = ALL_PRESETS;
24 |
25 | this.link = null;
26 |
27 | this.hoveredParam = null;
28 |
29 | this.getSounds = function() {
30 | return this.history.getSounds();
31 | };
32 |
33 | this.getSound = function() {
34 | return this.history.getCurrentSound();
35 | };
36 |
37 | this.currentSoundIndex = function() {
38 | return this.history.getCurrentIndex();
39 | };
40 |
41 | this.setCurrentSoundIndex = function(index) {
42 | this.history.setCurrentIndex(index);
43 | };
44 |
45 | this.deleteSound = function(index) {
46 | this.history.deleteSound(index);
47 | };
48 |
49 | this.isPlaying = function() {
50 | return player.playing;
51 | };
52 |
53 | this.togglePlay = function() {
54 | if (player.playing) {
55 | player.stop();
56 | } else {
57 | player.play(this.buffer);
58 | }
59 | };
60 |
61 | this.getFrequencyData = function() {
62 | return player.getFrequencyData();
63 | };
64 |
65 | this.openSound = function() {
66 | fileStorage.loadJfxrs().then(function(sounds) {
67 | for (var i = 0; i < sounds.length; i++) {
68 | this.history.addSound(sounds[i]);
69 | }
70 | }.bind(this), function(error) {
71 | console.error('Could not load sounds', error); // eslint-disable-line no-console
72 | });
73 | };
74 |
75 | this.saveSound = function() {
76 | fileStorage.saveJfxr(this.getSound(), this.getSound().name);
77 | };
78 |
79 | this.duplicateSound = function() {
80 | this.history.duplicateSound(this.history.getCurrentIndex());
81 | };
82 |
83 | this.createLink = function() {
84 | // http://stackoverflow.com/questions/3213531/creating-a-new-location-object-in-javascript
85 | var url = document.createElement('a');
86 | url.href = window.location.href;
87 | url.hash = encodeURIComponent(this.getSound().serialize());
88 | this.link = url.href;
89 | };
90 |
91 | this.exportSound = function() {
92 | this.synth.run().then(function(clip) {
93 | fileStorage.downloadWav(clip, this.getSound().name);
94 | }.bind(this));
95 | };
96 |
97 | this.applyPreset = function(preset) {
98 | var sound;
99 | if (this.createNew) {
100 | sound = history.newSound(preset.name);
101 | } else {
102 | sound = this.getSound();
103 | sound.reset();
104 | }
105 | preset.applyTo(sound);
106 | };
107 |
108 | this.mutate = function() {
109 | Preset.mutate(this.getSound());
110 | };
111 |
112 | this.canUndo = function() {
113 | return this.history.canUndo();
114 | };
115 |
116 | this.undo = function() {
117 | this.history.undo();
118 | };
119 |
120 | this.keyDown = function(e) {
121 | if (e.target.tagName == 'INPUT' && e.target.type == 'text') {
122 | return;
123 | }
124 | if (e.keyCode == 32) { // space
125 | this.togglePlay();
126 | e.preventDefault();
127 | }
128 | };
129 |
130 | this.soundNameKeyDown = function(e, currentName) {
131 | switch (e.keyCode) {
132 | case 13: // Enter
133 | $timeout(function() { e.target.blur(); });
134 | e.preventDefault();
135 | break;
136 | case 27: // Esc
137 | e.target.value = currentName;
138 | $timeout(function() { e.target.blur(); });
139 | e.preventDefault();
140 | break;
141 | }
142 | };
143 |
144 | // Make sure there is always a sound to operate on.
145 | $scope.$watch(function() { return this.getSounds().length; }.bind(this), function(value) {
146 | if (value === 0) {
147 | this.applyPreset(this.presets[0]);
148 | }
149 | }.bind(this));
150 |
151 | $scope.$watch(function() { return this.analyserEnabled; }.bind(this), function(value) {
152 | if (angular.isDefined(value)) {
153 | localStorage.set('analyserEnabled', value);
154 | }
155 | });
156 |
157 | $scope.$watch(function() { return this.autoplay; }.bind(this), function(value) {
158 | if (angular.isDefined(value)) {
159 | localStorage.set('autoplayEnabled', value);
160 | }
161 | });
162 |
163 | $scope.$watch(function() { return this.createNew; }.bind(this), function(value) {
164 | if (angular.isDefined(value)) {
165 | localStorage.set('createNew', value);
166 | }
167 | });
168 |
169 | $scope.$watch(function() { return this.getSound().serialize(); }.bind(this), debounce(
170 | function(newValue, oldValue) {
171 | if (this.synth) {
172 | this.synth.cancel();
173 | this.synth = null;
174 | }
175 | player.stop();
176 | this.buffer = null;
177 | if (newValue !== undefined && newValue !== '') {
178 | this.synth = synthFactory(newValue);
179 | this.synth.run().then(function(clip) {
180 | this.buffer = context.createBuffer(1, clip.getNumSamples(), clip.getSampleRate());
181 | this.buffer.getChannelData(0).set(clip.toFloat32Array());
182 | if (this.autoplay && newValue !== oldValue) {
183 | player.play(this.buffer);
184 | }
185 | }.bind(this));
186 | }
187 | }.bind(this),
188 | 500
189 | ));
190 |
191 | $scope.$on('parammouseenter', function($event, param) {
192 | this.hoveredParam = param;
193 | }.bind(this));
194 |
195 | $scope.$on('parammouseleave', function(unused_$event, unused_param) {
196 | this.hoveredParam = null;
197 | }.bind(this));
198 |
199 | var parseHash = function() {
200 | var json = decodeURIComponent($window.location.hash.replace(/^#/, ''));
201 | $window.location.hash = '';
202 | if (json.length > 0) {
203 | var sound = new Sound();
204 | try {
205 | sound.parse(json);
206 | } catch (ex) {
207 | console.error('Could not parse sound from URL fragment', ex); // eslint-disable-line no-console
208 | return;
209 | }
210 | this.history.addSound(sound);
211 | }
212 | }.bind(this);
213 | parseHash();
214 |
215 | // Fire a ready event to be used for integrations (e.g. Electron iframe).
216 | // When running within an iframe, the event is emitted from the parent window
217 | // instead. Otherwise, it is emitted from the current window (since in that
218 | // case, window.parent == window).
219 | var readyEvent = new Event('jfxrReady');
220 | readyEvent.mainCtrl = this;
221 | if ($window.parent) {
222 | $window.parent.dispatchEvent(readyEvent);
223 | }
224 | }];
225 |
--------------------------------------------------------------------------------
/app/js/player.js:
--------------------------------------------------------------------------------
1 | export var context = [function() {
2 | return new AudioContext();
3 | }];
4 |
5 | export var Player = ['$rootScope', 'context', function(
6 | $rootScope, context) {
7 | var Player = function() {
8 | this.position = 0;
9 |
10 | this.playing = false;
11 |
12 | this.analyser = context.createAnalyser();
13 | this.analyser.fftSize = 512;
14 | this.analyser.smoothingTimeConstant = 0.5;
15 | this.analyser.connect(context.destination);
16 |
17 | this.frequencyData = new Float32Array(this.analyser.frequencyBinCount);
18 | for (var i = 0; i < this.frequencyData.length; i++) {
19 | this.frequencyData[i] = -100;
20 | }
21 |
22 | // Make sure that the AnalyserNode is tickled at a regular interval,
23 | // even if we paint the canvas at irregular intervals. This is needed
24 | // because smoothing is applied only when the data is requested.
25 | this.script = context.createScriptProcessor(1024);
26 | this.script.onaudioprocess = function(unused_e) {
27 | this.analyser.getFloatFrequencyData(this.frequencyData);
28 | }.bind(this);
29 | // Feed zeros into the analyser because otherwise it freezes up as soon
30 | // as the sound stops playing.
31 | this.script.connect(this.analyser);
32 | };
33 |
34 | Player.prototype.play = function(buffer) {
35 | // Always try resuming the context before starting playback:
36 | // https://goo.gl/7K7WLu
37 | context.resume().then(function() {
38 | if (this.playing) {
39 | this.stop();
40 | }
41 | this.source = context.createBufferSource();
42 | this.source.connect(this.analyser);
43 | this.source.buffer = buffer;
44 | this.source.start(0);
45 | this.source.onended = function() {
46 | this.playing = false;
47 | $rootScope.$apply();
48 | }.bind(this);
49 | this.playing = true;
50 | }.bind(this));
51 | };
52 |
53 | Player.prototype.stop = function() {
54 | if (!this.playing) {
55 | return;
56 | }
57 | this.source.stop(0);
58 | this.source.onended = null;
59 | this.source = null;
60 | this.playing = false;
61 | };
62 |
63 | Player.prototype.getFrequencyData = function() {
64 | return this.frequencyData;
65 | };
66 |
67 | return Player;
68 | }];
69 |
--------------------------------------------------------------------------------
/app/js/shims.js:
--------------------------------------------------------------------------------
1 | window.AudioContext =
2 | window.AudioContext ||
3 | window.webkitAudioContext;
4 |
5 | window.requestAnimationFrame =
6 | window.requestAnimationFrame ||
7 | window.webkitRequestAnimationFrame ||
8 | window.mozRequestAnimationFrame ||
9 | window.oRequestAnimationFrame ||
10 | window.msRequestAnimationFrame;
11 |
12 | export function missingBrowserFeatures() {
13 | var missing = [];
14 | if (window.Blob === undefined || window.FileReader === undefined ||
15 | window.URL === undefined || URL.createObjectURL === undefined) {
16 | missing.push('File API');
17 | }
18 | if (window.AudioContext === undefined) {
19 | missing.push('Web Audio');
20 | }
21 | if (window.HTMLCanvasElement === undefined) {
22 | missing.push('Canvas');
23 | }
24 | return missing;
25 | }
26 |
27 | export function callIfSaveAsBroken(callback) {
28 | // https://github.com/eligrey/FileSaver.js/issues/12#issuecomment-34557946
29 | var svg = new Blob([""], {type: "image/svg+xml;charset=utf-8"});
30 | var img = new Image();
31 | img.onerror = callback;
32 | img.src = URL.createObjectURL(svg);
33 | }
34 |
35 | export function haveWebWorkers() {
36 | if (!window.Worker) {
37 | console.log('Web workers not supported'); // eslint-disable-line no-console
38 | return false;
39 | }
40 |
41 | // Web worker cleanup is buggy on Chrome < 34.0.1847.131, see
42 | // https://code.google.com/p/chromium/issues/detail?id=361792
43 | var m = navigator.appVersion.match(/Chrome\/((\d+\.)*\d)/);
44 | if (m && m[1] && compareVersionStrings(m[1], '34.0.1847.131') < 0) {
45 | console.log('Web workers buggy and disabled, please update your browser'); // eslint-disable-line no-console
46 | return false;
47 | }
48 |
49 | return true;
50 | }
51 |
52 | function compareVersionStrings(a, b) {
53 | function toArray(x) {
54 | var array = x.split('.');
55 | for (var i = 0; i < array.length; i++) {
56 | array[i] = parseInt(array[i]);
57 | }
58 | return array;
59 | }
60 | a = toArray(a);
61 | b = toArray(b);
62 |
63 | for (var i = 0; i < Math.min(a.length, b.length); i++) {
64 | if (a[i] > b[i]) return 1;
65 | if (a[i] < b[i]) return -1;
66 | }
67 | if (a.length > b.length) return 1;
68 | if (a.length < b.length) return -1;
69 | return 0;
70 | }
71 |
--------------------------------------------------------------------------------
/app/js/storage.js:
--------------------------------------------------------------------------------
1 | export var localStorage = [function() {
2 | var LocalStorage = function() {
3 | this.data = window.localStorage || {};
4 | };
5 |
6 | LocalStorage.prototype.get = function(key, defaultValue) {
7 | var json = this.data[key];
8 | if (json === undefined) {
9 | return defaultValue;
10 | }
11 | return JSON.parse(json);
12 | };
13 |
14 | LocalStorage.prototype.set = function(key, value) {
15 | this.data[key] = JSON.stringify(value);
16 | };
17 |
18 | LocalStorage.prototype.delete = function(key) {
19 | this.data.removeItem(key);
20 | };
21 |
22 | return new LocalStorage();
23 | }];
24 |
--------------------------------------------------------------------------------
/app/js/synthfactory.js:
--------------------------------------------------------------------------------
1 | import { Synth } from '../../lib';
2 |
3 | export var synthFactory = ['$q', '$timeout', function($q, $timeout) {
4 | return function(str) {
5 | return new PromiseSynth(str, $timeout, $q);
6 | };
7 | }];
8 |
9 | var PromiseSynth = function(str, $timeout, $q) {
10 | Synth.call(this, str, $timeout);
11 | this.$q = $q;
12 | };
13 | PromiseSynth.prototype = Object.create(Synth.prototype);
14 |
15 | PromiseSynth.prototype.run = function() {
16 | if (this.deferred) {
17 | return this.deferred.promise;
18 | }
19 | this.deferred = this.$q.defer();
20 | var doneCallback = this.deferred.resolve.bind(this.deferred);
21 | Synth.prototype.run.call(this, doneCallback);
22 | return this.deferred.promise;
23 | };
24 |
25 | PromiseSynth.prototype.cancel = function() {
26 | if (!this.deferred) {
27 | return;
28 | }
29 | Synth.prototype.cancel.call(this);
30 | this.deferred.reject();
31 | this.deferred = null;
32 | };
33 |
--------------------------------------------------------------------------------
/app/js/ui.js:
--------------------------------------------------------------------------------
1 | import angular from 'angular';
2 |
3 | import { sign } from '../../lib';
4 |
5 | export var customParam = [function() {
6 | return {
7 | restrict: 'E',
8 | scope: {
9 | sound: '=',
10 | param: '@',
11 | },
12 | transclude: true,
13 | template:
14 | '' +
15 | '
{{sound[param].label}}
' +
16 | '
' +
17 | '
' +
18 | ' ' +
19 | ' ' +
20 | '
' +
21 | '
',
22 | };
23 | }];
24 |
25 | export var floatParam = [function() {
26 | return {
27 | restrict: 'E',
28 | scope: {
29 | sound: '=',
30 | param: '@',
31 | },
32 | template:
33 | '' +
34 | ' ' +
35 | ' ' +
36 | '
' +
37 | ' ' +
38 | ' ' +
39 | ' —' +
40 | '
' +
41 | ' {{sound[param].unit}}
' +
42 | '',
43 | controller: ['$scope', function($scope) {
44 | // These are bound by ngModel; do not use them for anything else directly.
45 | this.rangeValue = '';
46 | this.textValue = '';
47 |
48 | // If r is the value on the range slider, and p the corresponding value of the parameter:
49 | // p = (2^abs(r) - 1) * sign(r)
50 | // This works for negative numbers and ensures continuity (and even differentiability)
51 | // through 0, but loses precision for numbers close to 0.
52 | function fromLog(r) {
53 | return sign(r) * (Math.pow(2, Math.abs(r)) - 1);
54 | }
55 | function toLog(p) {
56 | return sign(p) * Math.log(Math.abs(p) + 1) / Math.log(2);
57 | }
58 |
59 | var param = null;
60 | var logarithmic = false;
61 | this.minValue = 0;
62 | this.maxValue = 0;
63 | this.step = 0;
64 | $scope.$watch(function() { return $scope.sound[$scope.param]; }, function(p) {
65 | if (!p) return;
66 | param = p;
67 | logarithmic = param.logarithmic;
68 | if (logarithmic) {
69 | this.minValue = toLog(param.minValue);
70 | this.maxValue = toLog(param.maxValue);
71 | this.step = 1e-99;
72 | } else {
73 | this.minValue = param.minValue;
74 | this.maxValue = param.maxValue;
75 | this.step = param.step;
76 | }
77 | }.bind(this));
78 |
79 | this.getRangeValue = function() {
80 | if (logarithmic) {
81 | return fromLog(parseFloat(this.rangeValue));
82 | } else {
83 | return this.rangeValue;
84 | }
85 | };
86 |
87 | this.setRangeValue = function(value) {
88 | if (logarithmic) {
89 | this.rangeValue = toLog(value);
90 | } else {
91 | this.rangeValue = value;
92 | }
93 | };
94 |
95 | this.getTextValue = function() {
96 | return this.textValue;
97 | };
98 |
99 | this.setTextValue = function(value) {
100 | this.textValue = value;
101 | };
102 |
103 | this.getParamValue = function() {
104 | if (!param) return null;
105 | return param.value;
106 | };
107 |
108 | this.setParamValue = function(value) {
109 | if (!param) return;
110 | param.value = value;
111 | };
112 |
113 | this.stepParam = function(delta) {
114 | if (!param) return;
115 | var value = this.getParamValue();
116 | delta = sign(delta);
117 | if (logarithmic) {
118 | value -= delta * param.step;
119 | } else {
120 | value /= param.step;
121 | }
122 | this.setParamValue(value);
123 | };
124 | }],
125 | controllerAs: 'ctrl',
126 | link: function(scope, element, attrs, ctrl) {
127 | element.bind('wheel', function(e) {
128 | if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey || e.buttons) {
129 | return;
130 | }
131 | var delta = e.deltaX + e.deltaY;
132 | ctrl.stepParam(delta);
133 | scope.$apply();
134 | e.preventDefault();
135 | });
136 |
137 | scope.$watch(ctrl.getParamValue.bind(ctrl), function(value) {
138 | ctrl.setRangeValue(value);
139 | ctrl.setTextValue(value);
140 | });
141 |
142 | var rangeInput = angular.element(element[0].getElementsByClassName('floatslider'));
143 | rangeInput.bind('input', function(unused_e) {
144 | var value = ctrl.getRangeValue();
145 | ctrl.setTextValue(value);
146 | ctrl.setParamValue(value);
147 | scope.$apply();
148 | });
149 |
150 | var textInput = angular.element(element[0].getElementsByClassName('floattext'));
151 | textInput.bind('blur', function(unused_e) {
152 | ctrl.setParamValue(ctrl.getTextValue());
153 | ctrl.setTextValue(ctrl.getParamValue()); // Propagates clamping etc. back to the text input.
154 | scope.$apply();
155 | });
156 | textInput.bind('keydown', function(e) {
157 | switch (e.keyCode) {
158 | case 13: // Enter
159 | textInput[0].blur();
160 | e.preventDefault();
161 | break;
162 | case 27: // Esc
163 | ctrl.setTextValue(ctrl.getParamValue());
164 | textInput[0].blur();
165 | e.preventDefault();
166 | break;
167 | }
168 | });
169 | },
170 | };
171 | }];
172 |
173 | export var booleanParam = [function() {
174 | return {
175 | restrict: 'E',
176 | scope: {
177 | sound: '=',
178 | param: '@',
179 | },
180 | template:
181 | '' +
182 | ' ' +
183 | ' ' +
184 | '
' +
185 | ' ' +
186 | ' {{sound[param].valueTitle()}}' +
187 | ' —' +
188 | '
' +
189 | '',
190 | link: function(scope, element, unused_attrs, unused_ctrl) {
191 | element.bind('wheel', function(e) {
192 | if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey || e.buttons) {
193 | return;
194 | }
195 | var delta = e.deltaX + e.deltaY;
196 | scope.$apply(function() {
197 | var param = scope.sound[scope.param];
198 | param.value -= sign(delta) * param.step;
199 | });
200 | e.preventDefault();
201 | });
202 |
203 | // Something funny is going on with initialization of range elements with float values.
204 | // E.g. without this, the sustain slider will start at the 0 position. Angular bug?
205 | var unwatch = scope.$watch('sound[param].value', function(value) {
206 | if (value !== undefined) {
207 | element.find('input')[0].value = value;
208 | unwatch();
209 | }
210 | });
211 | },
212 | };
213 | }];
214 |
215 | export var waveformButton = [function() {
216 | return {
217 | require: 'ngModel',
218 | scope: {
219 | title: '@',
220 | waveform: '@waveformButton',
221 | ngModel: '=',
222 | },
223 | template:
224 | '',
229 | link: function(scope, element, attrs, modelCtrl) {
230 | var input = element.find('input');
231 | var value = scope.waveform;
232 |
233 | scope.$watch(function() { return input[0].checked; }, function(checked) {
234 | scope.checked = checked;
235 | });
236 |
237 | modelCtrl.$render = function() {
238 | input[0].checked = (modelCtrl.$viewValue == value);
239 | };
240 | input.bind('click', function() {
241 | scope.$apply(function() {
242 | if (input[0].checked) {
243 | modelCtrl.$setViewValue(value);
244 | }
245 | });
246 | });
247 | },
248 | };
249 | }];
250 |
251 | export var linkbox = ['$document', '$timeout', function($document, $timeout) {
252 | return {
253 | scope: {
254 | for: '=',
255 | },
256 | template: '',
257 | link: function(scope, element, unused_attrs, unused_ctrl) {
258 | var input = element.find('input');
259 | input.on('blur', function() {
260 | scope['for'] = null;
261 | scope.$apply();
262 | });
263 | scope.$watch('for', function(value) {
264 | if (value) {
265 | $timeout(function() {
266 | input[0].focus();
267 | input[0].setSelectionRange(0, value.length);
268 | });
269 | }
270 | });
271 | },
272 | };
273 | }];
274 |
--------------------------------------------------------------------------------
/app/js/waveshape.js:
--------------------------------------------------------------------------------
1 | export var canvasManager = [function() {
2 | return {
3 | controller: ['$element', function($element) {
4 | var canvas = $element[0];
5 | var context = canvas.getContext('2d');
6 | var width = 0;
7 | var height = 0;
8 | var drawFunctions = [];
9 |
10 | this.registerDrawFunction = function(drawFunction) {
11 | drawFunctions.push(drawFunction);
12 | };
13 |
14 | this.draw = function() {
15 | width = canvas.clientWidth;
16 | height = canvas.clientHeight;
17 | if (canvas.width != width) {
18 | canvas.width = width;
19 | }
20 | if (canvas.height != height) {
21 | canvas.height = height;
22 | }
23 |
24 | context.globalAlpha = 1.0;
25 | context.clearRect(0, 0, width, height);
26 |
27 | for (var i = 0; i < drawFunctions.length; i++) {
28 | drawFunctions[i](context, width, height);
29 | }
30 | };
31 | }],
32 | };
33 | }];
34 |
35 | export var waveshape = [function() {
36 | return {
37 | require: 'canvasManager',
38 | link: function(scope, element, attrs, ctrl) {
39 | var buffer = null;
40 |
41 | ctrl.registerDrawFunction(function(context, width, height) {
42 | if (!buffer) return;
43 |
44 | var channel = buffer.getChannelData(0);
45 | var numSamples = buffer.length;
46 |
47 | context.strokeStyle = '#fff';
48 | context.globalAlpha = 0.1;
49 | context.lineWidth = 1.0;
50 | context.beginPath();
51 | context.moveTo(0, height / 2);
52 | context.lineTo(width, height / 2);
53 | context.stroke();
54 |
55 | context.strokeStyle = '#57d';
56 | context.globalAlpha = 1.0;
57 |
58 | var i;
59 | var sample;
60 |
61 | if (numSamples < width) {
62 | // Draw a line between each pair of successive samples.
63 | context.beginPath();
64 | for (i = 0; i < numSamples; i++) {
65 | sample = channel[i];
66 | context.lineTo(i / numSamples * width, (1 - sample) * height / 2);
67 | }
68 | context.stroke();
69 | } else {
70 | // More samples than pixels. At a 5s buffer, drawing all samples
71 | // takes 300ms. For performance, draw a vertical line in each pixel
72 | // column, representing the range of samples falling into this
73 | // column.
74 | // TODO: make this look smoother by taking advantage of antialiasing somehow
75 | for (var x = 0; x < width; x++) {
76 | var min = 1e99, max = -1e99;
77 | var start = Math.floor(x / width * numSamples);
78 | var end = Math.ceil((x + 1) / width * numSamples);
79 | for (i = start; i < end; i++) {
80 | sample = channel[i];
81 | if (sample < min) min = sample;
82 | if (sample > max) max = sample;
83 | }
84 | context.beginPath();
85 | context.moveTo(x + 0.5, (1 - min) * height / 2 - 0.5);
86 | context.lineTo(x + 0.5, (1 - max) * height / 2 + 0.5);
87 | context.stroke();
88 | }
89 | }
90 | });
91 |
92 | scope.$watch(attrs.waveshape, function(value) {
93 | buffer = value;
94 | ctrl.draw();
95 | });
96 | },
97 | };
98 | }];
99 |
100 | export var drawAmplitude = [function() {
101 | return {
102 | require: 'canvasManager',
103 | link: function(scope, element, attrs, ctrl) {
104 | var sound = null;
105 |
106 | ctrl.registerDrawFunction(function(context, width, height) {
107 | if (!sound) return;
108 |
109 | var duration = sound.duration();
110 | var baseY = height - 0.5;
111 | var scaleY = -(height - 1) / (1 + sound.sustainPunch.value / 100);
112 |
113 | context.strokeStyle = '#d66';
114 | context.globalAlpha = 1.0;
115 | context.lineWidth = 1.0;
116 | context.beginPath();
117 | for (var x = 0; x < width; x++) {
118 | var time = x / width * duration;
119 | context.lineTo(x, baseY + sound.amplitudeAt(time) * scaleY);
120 | }
121 | context.stroke();
122 | });
123 |
124 | scope.$watch(attrs.drawAmplitude + '.serialize()', function(unused_value) {
125 | sound = scope.$eval(attrs.drawAmplitude);
126 | ctrl.draw();
127 | });
128 | },
129 | };
130 | }];
131 |
132 | export var drawFrequency = [function() {
133 | return {
134 | require: 'canvasManager',
135 | link: function(scope, element, attrs, ctrl) {
136 | var sound = null;
137 |
138 | ctrl.registerDrawFunction(function(context, width, height) {
139 | if (!sound) return;
140 |
141 | var duration = sound.duration();
142 |
143 | var min = 0;
144 | var max = 0;
145 | var x;
146 | for (x = 0; x < width; x++) {
147 | var f = sound.frequencyAt(x / width * duration);
148 | max = Math.max(max, f);
149 | }
150 | var baseY;
151 | var scaleY;
152 | if (max - min > 0) {
153 | scaleY = -(height - 1) / (max - min);
154 | baseY = height - 0.5 - min * scaleY;
155 | } else {
156 | scaleY = 0;
157 | baseY = height / 2;
158 | }
159 |
160 | context.strokeStyle = '#bb5';
161 | context.globalAlpha = 1.0;
162 | context.lineWidth = 1.0;
163 | context.beginPath();
164 | for (x = 0; x < width; x++) {
165 | var time = x / width * duration;
166 | context.lineTo(x, baseY + sound.frequencyAt(time) * scaleY);
167 | }
168 | context.stroke();
169 | });
170 |
171 | scope.$watch(attrs.drawFrequency + '.serialize()', function(unused_value) {
172 | sound = scope.$eval(attrs.drawFrequency);
173 | ctrl.draw();
174 | });
175 | },
176 | };
177 | }];
178 |
--------------------------------------------------------------------------------
/app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "jfxr-app",
3 | "version": "0.14.2",
4 | "description": "A browser-based tool to create sound effects for games.",
5 | "homepage": "http://jfxr.frozenfractal.com",
6 | "repository": "https://github.com/ttencate/jfxr",
7 | "bugs": "https://github.com/ttencate/jfxr/issues",
8 | "license": "BSD-3-Clause",
9 | "author": {
10 | "name": "Thomas ten Cate"
11 | },
12 | "scripts": {
13 | "clean": "rm -rf dist/",
14 | "build": "webpack --mode production",
15 | "watch": "webpack --mode development --watch",
16 | "publish": "yarn clean && yarn build && rsync -rv --delete --exclude=.* dist/ thomas@frozenfractal.com:/var/www/jfxr.frozenfractal.com/"
17 | },
18 | "devDependencies": {
19 | "angular": "^1.3.14",
20 | "css-loader": "^6.8.1",
21 | "eslint": "^8.24.0",
22 | "eslint-webpack-plugin": "^4.0.1",
23 | "file-saver": "^2.0.0-rc.4",
24 | "html-webpack-plugin": "^5.5.3",
25 | "image-minimizer-webpack-plugin": "^3.8.3",
26 | "imagemin": "^8.0.1",
27 | "imagemin-optipng": "^8.0.0",
28 | "mini-css-extract-plugin": "^2.7.6",
29 | "sass": "^1.68.0",
30 | "sass-loader": "^13.3.2",
31 | "terser-webpack-plugin": "^5.3.9",
32 | "webpack": "^5.74.0",
33 | "webpack-cli": "^4.10.0"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/app/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | const ESLintPlugin = require('eslint-webpack-plugin');
4 | const HtmlWebpackPlugin = require('html-webpack-plugin');
5 | const ImageMinimizerPlugin = require('image-minimizer-webpack-plugin');
6 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
7 | const TerserPlugin = require('terser-webpack-plugin');
8 |
9 | const outputPath = path.resolve(__dirname, 'dist');
10 |
11 | module.exports = {
12 | mode: 'production',
13 | entry: './js/index.js',
14 | output: {
15 | path: path.resolve(__dirname, 'dist'),
16 | filename: '[hash].js',
17 | },
18 | module: {
19 | rules: [
20 | {
21 | test: /\.scss$/,
22 | use: [
23 | MiniCssExtractPlugin.loader,
24 | {
25 | loader: 'css-loader',
26 | options: { sourceMap: true },
27 | },
28 | {
29 | loader: 'sass-loader',
30 | options: {
31 | sourceMap: true,
32 | },
33 | },
34 | ],
35 | },
36 | {
37 | test: /\.png$/,
38 | type: 'asset',
39 | },
40 | ],
41 | },
42 | optimization: {
43 | minimizer: [
44 | new TerserPlugin(),
45 | new ImageMinimizerPlugin({
46 | minimizer: {
47 | implementation: ImageMinimizerPlugin.imageminMinify,
48 | options: {
49 | plugins: [
50 | ['optipng', { optimizationLevel: 7 }],
51 | ],
52 | },
53 | },
54 | }),
55 | ],
56 | },
57 | devtool: 'source-map',
58 | plugins: [
59 | new ESLintPlugin({
60 | emitWarning: true,
61 | }),
62 | new HtmlWebpackPlugin({
63 | template: './index.html',
64 | }),
65 | new MiniCssExtractPlugin({
66 | filename: '[hash].css',
67 | }),
68 | ],
69 | stats: 'minimal',
70 | };
71 |
--------------------------------------------------------------------------------
/lib/.gitignore:
--------------------------------------------------------------------------------
1 | dist/
2 | node_modules/
3 | package-lock.json
4 |
--------------------------------------------------------------------------------
/lib/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2014, Thomas ten Cate
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions are met:
6 |
7 | 1. Redistributions of source code must retain the above copyright notice, this
8 | list of conditions and the following disclaimer.
9 |
10 | 2. Redistributions in binary form must reproduce the above copyright notice,
11 | this list of conditions and the following disclaimer in the documentation
12 | and/or other materials provided with the distribution.
13 |
14 | 3. Neither the name of the copyright holder nor the names of its contributors
15 | may be used to endorse or promote products derived from this software
16 | without specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 |
--------------------------------------------------------------------------------
/lib/README.md:
--------------------------------------------------------------------------------
1 | jfxr
2 | ====
3 |
4 | This library is the core of the [jfxr](https://jfxr.frozenfractal.com) sound
5 | effects generator. jfxr generates sounds from a small JSON object containing
6 | parameters like pitch, duration and effects.
7 |
8 | The library was developed only for running in the browser, but it can be made
9 | to work in a Node.js environment as well. If you need that, please [file an
10 | issue](https://github.com/ttencate/jfxr/issues).
11 |
12 | Installation
13 | ------------
14 |
15 | The module is built using UMD, so it should work with AMD, CommonJS, or as a
16 | browser global. Use one of these approaches:
17 |
18 | * To use it in the browser without any module system, you can use
19 | the minified bundle `dist/jfxr.min.js`, and include it via a `
22 |
23 | This will expose the API on the global `jfxr` object.
24 |
25 | * If you want to use it as a proper module:
26 |
27 | npm install --save jfxr
28 |
29 | Then import it and use it using one of:
30 |
31 | var jfxr = require('jfxr'); // Node.js syntax (CommonJS)
32 | import jfxr from 'jfxr'; // ES2015 module syntax
33 |
34 | Example
35 | -------
36 |
37 | This shows how you might run the synthesizer, and then play the resulting sound
38 | effect using the `AudioContext` API.
39 |
40 | var AudioContext = new AudioContext();
41 |
42 | var synth = new jfxr.Synth(mySound);
43 |
44 | synth.run(function(clip) {
45 | var buffer = context.createBuffer(1, clip.array.length, clip.sampleRate);
46 | buffer.getChannelData(0).set(clip.toFloat32Array());
47 | context.resume().then(function() {
48 | var source = context.createBufferSource();
49 | source.buffer = buffer;
50 | source.start(0);
51 | });
52 | });
53 |
54 | API
55 | ---
56 |
57 | ### `Synth`
58 |
59 | The `Synth` class is what produces the sound. Its interface is very simple:
60 |
61 | * `new Synth(str)` creates a new synth object which can render the sound
62 | described by the string `str`. This must be a valid JSON string as saved from
63 | the jfxr app (the contents of a `.jfxr` file).
64 |
65 | * `synth.run(callback)` starts synthesis asynchronously. When complete, the
66 | callback is invoked with a single parameter, `clip`, which is a `Clip`
67 | object.
68 |
69 | * `synth.cancel()` cancels any in-progress synthesis.
70 |
71 | ### `Clip`
72 |
73 | The `Clip` class represents a rendered sound effect. It's just a wrapper around
74 | an array of samples.
75 |
76 | * `clip.getNumSamples()` returns the number of audio samples in the clip.
77 |
78 | * `clip.getSampleRate()` returns the sample rate in Hertz, typically 44100.
79 |
80 | * `clip.toFloat32Array()` returns a `Float32Array` containing the generated
81 | samples (mono). Usually the values will be between -1 and 1.
82 |
83 | * `clip.toWavBytes()` returns a `Uint8Array` containing the raw bytes of a WAV
84 | file, encoded in 16-bits PCM.
85 |
--------------------------------------------------------------------------------
/lib/index.js:
--------------------------------------------------------------------------------
1 | export * from './src/math.js';
2 | export { Clip } from './src/clip.js';
3 | export { Sound } from './src/sound.js';
4 | export { Synth } from './src/synth.js';
5 | export { Preset, ALL_PRESETS } from './src/presets.js';
6 |
--------------------------------------------------------------------------------
/lib/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "jfxr",
3 | "version": "0.13.0",
4 | "description": "A library to render the sounds generated by jfxr.",
5 | "homepage": "http://jfxr.frozenfractal.com",
6 | "repository": "https://github.com/ttencate/jfxr",
7 | "bugs": "https://github.com/ttencate/jfxr/issues",
8 | "license": "BSD-3-Clause",
9 | "author": {
10 | "name": "Thomas ten Cate"
11 | },
12 | "main": "index.js",
13 | "scripts": {
14 | "clean": "rm -rf build/",
15 | "build": "webpack --mode production",
16 | "watch": "webpack --mode development --watch",
17 | "publish": "yarn clean && yarn build && yarn publish"
18 | },
19 | "devDependencies": {
20 | "eslint": "^8.24.0",
21 | "eslint-webpack-plugin": "^4.0.1",
22 | "webpack": "^5.74.0",
23 | "webpack-cli": "^4.10.0"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/lib/src/clip.js:
--------------------------------------------------------------------------------
1 | import { clamp } from './math.js';
2 |
3 | /**
4 | * Represents a generated sound effect.
5 | */
6 | export function Clip(array, sampleRate) {
7 | this.array = array;
8 | this.sampleRate = sampleRate;
9 | }
10 |
11 | Clip.prototype.getSampleRate = function() {
12 | return this.sampleRate;
13 | };
14 |
15 | Clip.prototype.getNumSamples = function() {
16 | return this.array.length;
17 | };
18 |
19 | Clip.prototype.toFloat32Array = function() {
20 | return this.array;
21 | };
22 |
23 | Clip.prototype.toWavBytes = function() {
24 | var floats = this.array;
25 | var numSamples = floats.length;
26 |
27 | // http://soundfile.sapp.org/doc/WaveFormat/
28 |
29 | var fileLength = 44 + numSamples * 2;
30 | var bytes = new Uint8Array(fileLength);
31 | var nextIndex = 0;
32 |
33 | function byte(value) {
34 | bytes[nextIndex++] = value;
35 | }
36 |
37 | function asciiString(value) {
38 | for (var i = 0; i < value.length; i++) {
39 | byte(value.charCodeAt(i));
40 | }
41 | }
42 |
43 | function uint16le(value) {
44 | byte(value & 0xFF);
45 | value >>= 8;
46 | byte(value & 0xFF);
47 | }
48 |
49 | function uint32le(value) {
50 | byte(value & 0xFF);
51 | value >>= 8;
52 | byte(value & 0xFF);
53 | value >>= 8;
54 | byte(value & 0xFF);
55 | value >>= 8;
56 | byte(value & 0xFF);
57 | }
58 |
59 | asciiString('RIFF'); // RIFF identifier
60 | uint32le(fileLength - 8); // size following this number
61 | asciiString('WAVE'); // RIFF type
62 |
63 | asciiString('fmt '); // format subchunk identifier
64 | uint32le(16); // format subchunk length
65 | uint16le(1); // sample format: PCM
66 | uint16le(1); // channel count
67 | uint32le(this.sampleRate); // sample rate
68 | uint32le(this.sampleRate * 2); // byte rate: sample rate * block align
69 | uint16le(2); // block align
70 | uint16le(16); // bits per sample
71 |
72 | asciiString('data'); // data subchunk length
73 | uint32le(numSamples * 2); // data subchunk length
74 |
75 | for (var i = 0; i < floats.length; i++) {
76 | uint16le(clamp(-0x8000, 0x7FFF, Math.round(floats[i] * 0x8000)));
77 | }
78 |
79 | return bytes;
80 | };
81 |
--------------------------------------------------------------------------------
/lib/src/math.js:
--------------------------------------------------------------------------------
1 | // stackoverflow.com/questions/21363064/chrome-chromium-doesnt-know-javascript-function-math-sign
2 | export function sign(x) {
3 | if (+x === x) { // check if a number was given
4 | return (x === 0) ? x : (x > 0) ? 1 : -1;
5 | }
6 | return NaN;
7 | }
8 |
9 | export function frac(x) {
10 | return x - Math.floor(x);
11 | }
12 |
13 | export function clamp(min, max, x) {
14 | if (x < min) return min;
15 | if (x > max) return max;
16 | return x;
17 | }
18 |
19 | export function roundTo(x, multiple) {
20 | return Math.round(x / multiple) * multiple;
21 | }
22 |
23 | export function lerp(a, b, f) {
24 | return (1 - f) * a + f * b;
25 | }
26 |
--------------------------------------------------------------------------------
/lib/src/presets.js:
--------------------------------------------------------------------------------
1 | import { roundTo } from './math.js';
2 | import { Random } from './random.js';
3 |
4 | export var Preset = function(args) {
5 | this.name = args.name;
6 | this.applyTo = args.applyTo || null;
7 | this.random = new Random();
8 | };
9 |
10 | Preset.prototype.randomize = function(param, min, max) {
11 | if (min === undefined) min = param.minValue;
12 | if (max === undefined) max = param.maxValue;
13 | switch (param.type) {
14 | case 'boolean':
15 | param.value = (this.random.uniform() >= 0.5);
16 | break;
17 | case 'float':
18 | param.value = roundTo(this.random.uniform(min, max), param.step);
19 | break;
20 | case 'int':
21 | param.value = this.random.int(min, max);
22 | break;
23 | case 'enum':
24 | var values = [];
25 | for (var v in param.values) {
26 | values.push(v);
27 | }
28 | param.value = this.random.fromArray(values);
29 | break;
30 | }
31 | };
32 |
33 | Preset.mutate = function(sound) {
34 | var random = new Random();
35 | sound.forEachParam(function(key, param) {
36 | if (param.locked) return;
37 | if (key == 'normalization' || key == 'amplification') return;
38 | switch (param.type) {
39 | case 'boolean':
40 | if (random.boolean(0.1)) {
41 | param.value = !param.value;
42 | }
43 | break;
44 | case 'float':
45 | if (param.value != param.defaultValue || random.boolean(0.3)) {
46 | var range = 0.05 * (param.maxValue - param.minValue);
47 | param.value = roundTo(param.value + random.uniform(-range, range), param.step);
48 | }
49 | break;
50 | case 'int':
51 | param.value += random.int(-1, 1);
52 | break;
53 | case 'enum':
54 | if (random.boolean(0.1)) {
55 | var values = [];
56 | for (var v in param.values) {
57 | values.push(v);
58 | }
59 | param.value = random.fromArray(values);
60 | }
61 | break;
62 | }
63 | });
64 | };
65 |
66 | export var ALL_PRESETS = [
67 | new Preset({
68 | name: 'Default',
69 | applyTo: function(sound) {
70 | sound.sustain.value = 0.2;
71 | return sound;
72 | },
73 | }),
74 |
75 | new Preset({
76 | name: 'Random',
77 | applyTo: function(sound) {
78 | var random = this.random;
79 | var randomize = this.randomize.bind(this);
80 |
81 | var attackSustainDecay = random.int(3, 16);
82 | // Attack typically leads to less useful sounds. Reduce probability by requiring two bits.
83 | if ((attackSustainDecay & 1) && (attackSustainDecay & 2)) {
84 | randomize(sound.attack, 0.0, 2.0);
85 | }
86 | // For the other parameters, use just one bit.
87 | if (attackSustainDecay & 4) {
88 | randomize(sound.sustain, 0.0, 1.0);
89 | if (random.boolean(0.5)) {
90 | randomize(sound.sustainPunch);
91 | }
92 | }
93 | if (attackSustainDecay & 8) {
94 | randomize(sound.decay);
95 | }
96 |
97 | if (random.boolean(0.5)) {
98 | randomize(sound.tremoloDepth);
99 | randomize(sound.tremoloFrequency);
100 | }
101 |
102 | randomize(sound.frequency);
103 | if (random.boolean(0.5)) {
104 | randomize(sound.frequencySweep);
105 | }
106 | if (random.boolean(0.5)) {
107 | randomize(sound.frequencyDeltaSweep);
108 | }
109 |
110 | var repeatJump = random.int(0, 3);
111 | if (repeatJump >= 1) {
112 | randomize(sound.repeatFrequency,
113 | 1 / (sound.attack.value + sound.sustain.value + sound.decay.value),
114 | sound.repeatFrequency.maxValue);
115 | }
116 | if (repeatJump >= 2) {
117 | randomize(sound.frequencyJump1Onset);
118 | randomize(sound.frequencyJump1Amount);
119 | if (random.boolean(0.5)) {
120 | randomize(sound.frequencyJump2Onset);
121 | randomize(sound.frequencyJump2Amount);
122 | if (sound.frequencyJump2Onset.value < sound.frequencyJump1Onset.value) {
123 | var tmp = sound.frequencyJump1Onset.value;
124 | sound.frequencyJump1Onset.value = sound.frequencyJump2Onset.value;
125 | sound.frequencyJump2Onset.value = tmp;
126 | }
127 | }
128 | }
129 |
130 | if (random.boolean(0.5)) {
131 | randomize(sound.harmonics);
132 | randomize(sound.harmonicsFalloff);
133 | }
134 |
135 | randomize(sound.waveform);
136 | randomize(sound.interpolateNoise);
137 |
138 | if (random.boolean(0.5)) {
139 | randomize(sound.vibratoDepth);
140 | randomize(sound.vibratoFrequency);
141 | }
142 | if (sound.waveform.value == 'square' && random.boolean(0.5)) {
143 | randomize(sound.squareDuty);
144 | randomize(sound.squareDutySweep);
145 | }
146 |
147 | if (random.boolean(0.5)) {
148 | randomize(sound.flangerOffset);
149 | if (random.boolean(0.5)) {
150 | randomize(sound.flangerOffsetSweep);
151 | }
152 | }
153 |
154 | if (random.boolean(0.2)) {
155 | randomize(sound.bitCrush);
156 | if (random.boolean(0.5)) {
157 | randomize(sound.bitCrushSweep);
158 | }
159 | }
160 |
161 | do {
162 | sound.lowPassCutoff.reset();
163 | sound.lowPassCutoffSweep.reset();
164 | sound.highPassCutoff.reset();
165 | sound.highPassCutoffSweep.reset();
166 | if (random.boolean(0.5)) {
167 | randomize(sound.lowPassCutoff, 0, 10000);
168 | }
169 | if (random.boolean(0.5)) {
170 | randomize(sound.highPassCutoffSweep, 0, 10000);
171 | }
172 | if (random.boolean(0.5)) {
173 | randomize(sound.highPassCutoff, 0, 10000);
174 | }
175 | if (random.boolean(0.5)) {
176 | randomize(sound.highPassCutoffSweep, 0, 10000);
177 | }
178 | } while (
179 | (sound.lowPassCutoff.value > sound.highPassCutoff.value) &&
180 | (sound.lowPassCutoff.value + sound.lowPassCutoffSweep.value > sound.highPassCutoff.value + sound.highPassCutoffSweep.value)
181 | );
182 |
183 | if (random.boolean(0.5)) {
184 | randomize(sound.compression, 0.5, 2.0);
185 | }
186 |
187 | sound.normalization.value = true;
188 | sound.amplification.value = 100;
189 |
190 | return sound;
191 | },
192 | }),
193 |
194 | new Preset({
195 | name: 'Pickup/coin',
196 | applyTo: function(sound) {
197 | var random = this.random;
198 | var randomize = this.randomize.bind(this);
199 |
200 | sound.waveform.value = random.fromArray(['sine', 'square', 'whistle', 'breaker']);
201 | randomize(sound.squareDuty);
202 | randomize(sound.squareDutySweep);
203 |
204 | randomize(sound.sustain, 0.02, 0.1);
205 | if (random.boolean(0.5)) {
206 | randomize(sound.sustainPunch, 0, 100);
207 | }
208 | randomize(sound.decay, 0.05, 0.4);
209 |
210 | randomize(sound.frequency, 100, 2000);
211 | if (random.boolean(0.7)) {
212 | randomize(sound.frequencyJump1Onset, 10, 30);
213 | randomize(sound.frequencyJump1Amount, 10, 100);
214 | if (random.boolean(0.3)) {
215 | randomize(sound.frequencyJump2Onset, 20, 40);
216 | randomize(sound.frequencyJump2Amount, 10, 100);
217 | }
218 | }
219 |
220 | if (random.boolean(0.5)) {
221 | randomize(sound.flangerOffset, 0, 10);
222 | randomize(sound.flangerOffsetSweep, -10, 10);
223 | }
224 |
225 | return sound;
226 | }
227 | }),
228 |
229 | new Preset({
230 | name: 'Laser/shoot',
231 | applyTo: function(sound) {
232 | var random = this.random;
233 | var randomize = this.randomize.bind(this);
234 |
235 | sound.waveform.value = random.fromArray(['sine', 'triangle', 'sawtooth', 'square', 'tangent', 'whistle', 'breaker']);
236 | randomize(sound.squareDuty);
237 | randomize(sound.squareDutySweep);
238 |
239 | randomize(sound.sustain, 0.02, 0.1);
240 | if (random.boolean(0.5)) {
241 | randomize(sound.sustainPunch, 0, 100);
242 | }
243 | randomize(sound.decay, 0.02, 0.1);
244 |
245 | randomize(sound.frequency, 500, 2000);
246 | randomize(sound.frequencySweep, -200, -2000);
247 | randomize(sound.frequencyDeltaSweep, -200, -2000);
248 |
249 | if (random.boolean(0.5)) {
250 | randomize(sound.vibratoDepth, 0, 0.5 * sound.frequency.value);
251 | randomize(sound.vibratoFrequency, 0, 100);
252 | }
253 |
254 | if (random.boolean(0.5)) {
255 | randomize(sound.flangerOffset, 0, 10);
256 | randomize(sound.flangerOffsetSweep, -10, 10);
257 | }
258 |
259 | return sound;
260 | }
261 | }),
262 |
263 | new Preset({
264 | name: 'Explosion',
265 | applyTo: function(sound) {
266 | var random = this.random;
267 | var randomize = this.randomize.bind(this);
268 |
269 | sound.waveform.value = random.fromArray(['whitenoise', 'pinknoise', 'brownnoise']);
270 | randomize(sound.interpolateNoise);
271 |
272 | randomize(sound.sustain, 0.05, 0.1);
273 | if (random.boolean(0.5)) {
274 | randomize(sound.sustainPunch, 0, 100);
275 | }
276 | randomize(sound.decay, 0.3, 0.5);
277 |
278 | if (sound.waveform.value == 'brownnoise') {
279 | randomize(sound.frequency, 10000, 20000);
280 | } else {
281 | randomize(sound.frequency, 1000, 10000);
282 | }
283 | randomize(sound.frequencySweep, -1000, -5000);
284 | randomize(sound.frequencyDeltaSweep, -1000, -5000);
285 |
286 | if (random.boolean(0.5)) {
287 | randomize(sound.tremoloDepth, 0, 50);
288 | randomize(sound.tremoloFrequency, 0, 100);
289 | }
290 |
291 | if (random.boolean(0.5)) {
292 | randomize(sound.flangerOffset, 0, 10);
293 | randomize(sound.flangerOffsetSweep, -10, 10);
294 | }
295 |
296 | if (random.boolean(0.5)) {
297 | randomize(sound.compression, 0.5, 2.0);
298 | }
299 |
300 | return sound;
301 | }
302 | }),
303 |
304 | new Preset({
305 | name: 'Powerup',
306 | applyTo: function(sound) {
307 | var random = this.random;
308 | var randomize = this.randomize.bind(this);
309 |
310 | sound.waveform.value = random.fromArray(['sine', 'triangle', 'sawtooth', 'square', 'tangent', 'whistle', 'breaker']);
311 | randomize(sound.squareDuty);
312 | randomize(sound.squareDutySweep);
313 |
314 | randomize(sound.sustain, 0.05, 0.2);
315 | if (random.boolean(0.5)) {
316 | randomize(sound.sustainPunch, 0, 100);
317 | }
318 | randomize(sound.decay, 0.1, 0.4);
319 |
320 | randomize(sound.frequency, 500, 2000);
321 | randomize(sound.frequencySweep, 0, 2000);
322 | randomize(sound.frequencyDeltaSweep, 0, 2000);
323 | if (random.boolean(0.5)) {
324 | randomize(sound.repeatFrequency, 0, 20);
325 | }
326 | if (random.boolean(0.5)) {
327 | randomize(sound.vibratoDepth);
328 | randomize(sound.vibratoFrequency);
329 | }
330 |
331 | return sound;
332 | }
333 | }),
334 |
335 | new Preset({
336 | name: 'Hit/hurt',
337 | applyTo: function(sound) {
338 | var random = this.random;
339 | var randomize = this.randomize.bind(this);
340 |
341 | sound.waveform.value = random.fromArray(['sawtooth', 'square', 'tangent', 'whitenoise', 'pinknoise', 'brownnoise']);
342 |
343 | randomize(sound.sustain, 0.02, 0.1);
344 | if (random.boolean(0.5)) {
345 | randomize(sound.sustainPunch, 0, 100);
346 | }
347 | randomize(sound.decay, 0.02, 0.1);
348 |
349 | randomize(sound.frequency, 500, 1000);
350 | randomize(sound.frequencySweep, -200, -1000);
351 | randomize(sound.frequencyDeltaSweep, -200, -1000);
352 |
353 | if (random.boolean(0.5)) {
354 | randomize(sound.flangerOffset, 0, 10);
355 | randomize(sound.flangerOffsetSweep, -10, 10);
356 | }
357 |
358 | randomize(sound.lowPassCutoffSweep);
359 |
360 | return sound;
361 | }
362 | }),
363 |
364 | new Preset({
365 | name: 'Jump',
366 | applyTo: function(sound) {
367 | var random = this.random;
368 | var randomize = this.randomize.bind(this);
369 |
370 | sound.waveform.value = random.fromArray(['sine', 'square', 'whistle', 'breaker']);
371 | randomize(sound.squareDuty);
372 | randomize(sound.squareDutySweep);
373 |
374 | randomize(sound.sustain, 0.02, 0.1);
375 | if (random.boolean(0.5)) {
376 | randomize(sound.sustainPunch, 0, 100);
377 | }
378 | randomize(sound.decay, 0.05, 0.4);
379 |
380 | randomize(sound.frequency, 100, 2000);
381 | randomize(sound.frequencySweep, 200, 2000);
382 |
383 | if (random.boolean(0.3)) {
384 | randomize(sound.flangerOffset, 0, 10);
385 | randomize(sound.flangerOffsetSweep, -10, 10);
386 | }
387 |
388 | if (random.boolean(0.5)) {
389 | randomize(sound.lowPassCutoff);
390 | }
391 | if (random.boolean(0.5)) {
392 | randomize(sound.highPassCutoff);
393 | }
394 |
395 | return sound;
396 | }
397 | }),
398 |
399 | new Preset({
400 | name: 'Blip/select',
401 | applyTo: function(sound) {
402 | var random = this.random;
403 | var randomize = this.randomize.bind(this);
404 |
405 | sound.waveform.value = random.fromArray(['sine', 'triangle', 'sawtooth', 'square', 'tangent', 'whistle', 'breaker']);
406 | randomize(sound.squareDuty, 10, 90);
407 |
408 | randomize(sound.sustain, 0.01, 0.07);
409 | randomize(sound.decay, 0, 0.03);
410 |
411 | randomize(sound.frequency, 100, 3000);
412 |
413 | if (random.boolean(0.5)) {
414 | randomize(sound.harmonics);
415 | randomize(sound.harmonicsFalloff);
416 | }
417 |
418 | return sound;
419 | }
420 | }),
421 | ];
422 |
--------------------------------------------------------------------------------
/lib/src/random.js:
--------------------------------------------------------------------------------
1 | // A fast, but not crytographically strong xorshift PRNG, to make up for
2 | // the lack of a seedable random number generator in JavaScript.
3 | // If seed is 0 or undefined, the current time is used.
4 | export var Random = function(seed) {
5 | if (!seed) seed = Date.now();
6 | this.x = seed & 0xffffffff;
7 | this.y = 362436069;
8 | this.z = 521288629;
9 | this.w = 88675123;
10 | // Mix it up, because some bits of the current Unix time are quite predictable.
11 | for (var i = 0; i < 32; i++) this.uint32();
12 | };
13 |
14 | Random.prototype.uint32 = function() {
15 | var t = this.x ^ ((this.x << 11) & 0xffffffff);
16 | this.x = this.y;
17 | this.y = this.z;
18 | this.z = this.w;
19 | this.w = (this.w ^ (this.w >>> 19) ^ (t ^ (t >>> 8)));
20 | return this.w + 0x80000000;
21 | };
22 |
23 | Random.prototype.uniform = function(min, max) {
24 | if (min === undefined && max === undefined) {
25 | min = 0;
26 | max = 1;
27 | } else if (max === undefined) {
28 | max = min;
29 | min = 0;
30 | }
31 | return min + (max - min) * this.uint32() / 0xffffffff;
32 | };
33 |
34 | Random.prototype.int = function(min, max) {
35 | return Math.floor(this.uniform(min, max));
36 | };
37 |
38 | Random.prototype.boolean = function(trueProbability) {
39 | return this.uniform() < trueProbability;
40 | };
41 |
42 | Random.prototype.fromArray = function(array) {
43 | return array[this.int(array.length)];
44 | };
45 |
--------------------------------------------------------------------------------
/lib/src/sound.js:
--------------------------------------------------------------------------------
1 | import { frac } from './math.js';
2 |
3 | /**
4 | * This is the version written out to sound files. We maintain backwards
5 | * compatibility with files written by older versions where possible, but
6 | * refuse to read files written by newer versions. Only bump the version number
7 | * if older versions of jfxr would be unable to correctly interpret files
8 | * written by this version.
9 | */
10 | export var VERSION = 1;
11 |
12 | export var Parameter = function(args) {
13 | this.label = args.label || '';
14 | this.description = args.description || '';
15 | this.unit = args.unit || '';
16 | this.type = args.type || 'float';
17 | var numeric = this.type == 'float' || this.type == 'int';
18 | this.value_ = args.defaultValue;
19 | this.defaultValue = this.value_;
20 | this.values = this.type == 'enum' ? (args.values || {}) : null;
21 | this.minValue = numeric ? args.minValue : null;
22 | this.maxValue = numeric ? args.maxValue : null;
23 | this.step = numeric ? (args.step || 'any') : null;
24 | this.logarithmic = !!(this.type == 'float' && args.logarithmic);
25 | this.digits = this.type == 'float' ? Math.max(0, Math.round(-Math.log(this.step) / Math.log(10))) : null;
26 | this.disabledReason_ = args.disabledReason || null;
27 | this.locked = false;
28 | };
29 |
30 | Object.defineProperty(Parameter.prototype, 'value', {
31 | enumerable: true,
32 | get: function() {
33 | return this.value_;
34 | },
35 | set: function(value) {
36 | switch (this.type) {
37 | case 'float':
38 | case 'int':
39 | if (typeof value == 'string') {
40 | value = parseFloat(value);
41 | }
42 | if (value != value) { // NaN
43 | break;
44 | }
45 | if (this.type == 'int') {
46 | value = Math.round(value);
47 | }
48 | if (this.minValue !== null && value < this.minValue) {
49 | value = this.minValue;
50 | }
51 | if (this.maxValue !== null && value > this.maxValue) {
52 | value = this.maxValue;
53 | }
54 | this.value_ = value;
55 | break;
56 | case 'enum':
57 | value = '' + value;
58 | if (!this.values[value]) {
59 | return;
60 | }
61 | this.value_ = value;
62 | break;
63 | case 'boolean':
64 | this.value_ = !!value;
65 | break;
66 | }
67 | },
68 | });
69 |
70 | Parameter.prototype.valueTitle = function() {
71 | if (this.type == 'enum') {
72 | return this.values[this.value_];
73 | }
74 | if (this.type == 'boolean') {
75 | return this.value_ ? 'Enabled' : 'Disabled';
76 | }
77 | };
78 |
79 | Parameter.prototype.isDisabled = function(sound) {
80 | return !!(this.disabledReason_ && this.disabledReason_(sound));
81 | };
82 |
83 | Parameter.prototype.whyDisabled = function(sound) {
84 | return this.disabledReason_ && this.disabledReason_(sound);
85 | };
86 |
87 | Parameter.prototype.toggleLocked = function() {
88 | this.locked = !this.locked;
89 | };
90 |
91 | Parameter.prototype.reset = function() {
92 | this.value = this.defaultValue;
93 | };
94 |
95 | Parameter.prototype.hasDefaultValue = function() {
96 | return this.value == this.defaultValue;
97 | };
98 |
99 | export var Sound = function() {
100 | this.name = 'Unnamed';
101 |
102 | var isNotSquare = function(sound) {
103 | if (sound.waveform.value != 'square') {
104 | return 'Duty cycle only applies to square waveforms';
105 | }
106 | return null;
107 | };
108 |
109 | // Sound properties
110 |
111 | this.sampleRate = new Parameter({
112 | label: 'Sample rate',
113 | unit: 'Hz',
114 | defaultValue: 44100,
115 | minValue: 44100,
116 | maxValue: 44100,
117 | disabledReason: function() { return 'Sample rate is currently not configurable'; },
118 | });
119 |
120 | // Amplitude parameters
121 |
122 | this.attack = new Parameter({
123 | label: 'Attack',
124 | description: 'Time from the start of the sound until the point where it reaches its maximum volume. Increase this for a gradual fade-in; decrease it to add more "punch".',
125 | unit: 's',
126 | defaultValue: 0,
127 | minValue: 0,
128 | maxValue: 5,
129 | step: 0.01,
130 | logarithmic: true,
131 | });
132 | this.sustain = new Parameter({
133 | label: 'Sustain',
134 | description: 'Amount of time for which the sound holds its maximum volume after the attack phase. Increase this to increase the sound\'s duration.',
135 | unit: 's',
136 | defaultValue: 0.0,
137 | minValue: 0,
138 | maxValue: 5,
139 | step: 0.01,
140 | logarithmic: true,
141 | });
142 | this.sustainPunch = new Parameter({
143 | label: 'Sustain punch',
144 | description: 'Additional volume at the start of the sustain phase, which linearly fades back to the base level. Use this to add extra "punch" to the sustain phase.',
145 | unit: '%',
146 | defaultValue: 0,
147 | minValue: 0,
148 | maxValue: 100,
149 | step: 10,
150 | });
151 | this.decay = new Parameter({
152 | label: 'Decay',
153 | description: 'Time it takes from the end of the sustain phase until the sound has faded away. Increase this for a gradual fade-out.',
154 | unit: 's',
155 | defaultValue: 0,
156 | minValue: 0,
157 | maxValue: 5,
158 | step: 0.01,
159 | logarithmic: true,
160 | });
161 | this.tremoloDepth = new Parameter({
162 | label: 'Tremolo depth',
163 | description: 'Amount by which the volume oscillates as a sine wave around its base value.',
164 | unit: '%',
165 | defaultValue: 0,
166 | minValue: 0,
167 | maxValue: 100,
168 | step: 1,
169 | });
170 | this.tremoloFrequency = new Parameter({
171 | label: 'Tremolo frequency',
172 | description: 'Frequency at which the volume oscillates as a sine wave around its base value.',
173 | unit: 'Hz',
174 | defaultValue: 10,
175 | minValue: 0,
176 | maxValue: 1000,
177 | step: 1,
178 | logarithmic: true,
179 | });
180 |
181 | // Pitch parameters
182 |
183 | this.frequency = new Parameter({
184 | label: 'Frequency',
185 | description: 'Initial frequency, or pitch, of the sound. This determines how high the sound starts out; higher values result in higher notes.',
186 | unit: 'Hz',
187 | defaultValue: 500,
188 | minValue: 10,
189 | maxValue: 10000,
190 | step: 100,
191 | logarithmic: true,
192 | });
193 | this.frequencySweep = new Parameter({
194 | label: 'Frequency sweep',
195 | description: 'Amount by which the frequency is changed linearly over the duration of the sound.',
196 | unit: 'Hz',
197 | defaultValue: 0,
198 | minValue: -10000,
199 | maxValue: 10000,
200 | step: 100,
201 | logarithmic: true,
202 | });
203 | this.frequencyDeltaSweep = new Parameter({
204 | label: 'Freq. delta sweep',
205 | description: 'Amount by which the frequency is changed quadratically over the duration of the sound.',
206 | unit: 'Hz',
207 | defaultValue: 0,
208 | minValue: -10000,
209 | maxValue: 10000,
210 | step: 100,
211 | logarithmic: true,
212 | });
213 | this.repeatFrequency = new Parameter({
214 | label: 'Repeat frequency',
215 | description: 'Amount of times per second that the frequency is reset to its base value, and starts its sweep cycle anew.',
216 | unit: 'Hz',
217 | defaultValue: 0,
218 | minValue: 0,
219 | maxValue: 100,
220 | step: 0.1,
221 | logarithmic: true,
222 | });
223 | this.frequencyJump1Onset = new Parameter({
224 | label: 'Freq. jump 1 onset',
225 | description: 'Point in time, as a fraction of the repeat cycle, at which the frequency makes a sudden jump.',
226 | unit: '%',
227 | defaultValue: 33,
228 | minValue: 0,
229 | maxValue: 100,
230 | step: 5,
231 | });
232 | this.frequencyJump1Amount = new Parameter({
233 | label: 'Freq. jump 1 amount',
234 | description: 'Amount by which the frequency jumps at the given onset, as a fraction of the current frequency.',
235 | unit: '%',
236 | defaultValue: 0,
237 | minValue: -100,
238 | maxValue: 100,
239 | step: 5,
240 | });
241 | this.frequencyJump2Onset = new Parameter({
242 | label: 'Freq. jump 2 onset',
243 | description: 'Point in time, as a fraction of the repeat cycle, at which the frequency makes a sudden jump.',
244 | unit: '%',
245 | defaultValue: 66,
246 | minValue: 0,
247 | maxValue: 100,
248 | step: 5,
249 | });
250 | this.frequencyJump2Amount = new Parameter({
251 | label: 'Freq. jump 2 amount',
252 | description: 'Amount by which the frequency jumps at the given onset, as a fraction of the current frequency.',
253 | unit: '%',
254 | defaultValue: 0,
255 | minValue: -100,
256 | maxValue: 100,
257 | step: 5,
258 | });
259 |
260 | // Harmonics parameters
261 |
262 | this.harmonics = new Parameter({
263 | label: 'Harmonics',
264 | description: 'Number of harmonics (overtones) to add. Generates the same sound at several multiples of the base frequency (2×, 3×, …), and mixes them with the original sound. Note that this slows down rendering quite a lot, so you may want to leave it at 0 until the last moment.',
265 | type: 'int',
266 | defaultValue: 0,
267 | minValue: 0,
268 | maxValue: 5,
269 | step: 1,
270 | });
271 | this.harmonicsFalloff = new Parameter({
272 | label: 'Harmonics falloff',
273 | description: 'Volume of each subsequent harmonic, as a fraction of the previous one.',
274 | defaultValue: 0.5,
275 | minValue: 0,
276 | maxValue: 1,
277 | step: 0.01,
278 | });
279 |
280 | // Tone parameters
281 |
282 | this.waveform = new Parameter({
283 | label: 'Waveform',
284 | description: 'Shape of the waveform. This is the most important factor in determining the character, or timbre, of the sound.',
285 | defaultValue: 'sine',
286 | type: 'enum',
287 | values: {
288 | 'sine': 'Sine',
289 | 'triangle': 'Triangle',
290 | 'sawtooth': 'Sawtooth',
291 | 'square': 'Square',
292 | 'tangent': 'Tangent',
293 | 'whistle': 'Whistle',
294 | 'breaker': 'Breaker',
295 | 'whitenoise': 'White noise',
296 | 'pinknoise': 'Pink noise',
297 | 'brownnoise': 'Brown noise',
298 | },
299 | });
300 | this.interpolateNoise = new Parameter({
301 | label: 'Interpolate noise',
302 | description: 'Whether to use linear interpolation between individual samples of noise. This results in a smoother sound.',
303 | defaultValue: true,
304 | type: 'boolean',
305 | disabledReason: function(sound) {
306 | var waveform = sound.waveform.value;
307 | if (waveform != 'whitenoise' && waveform != 'pinknoise' && waveform != 'brownnoise') {
308 | return 'Noise interpolation only applies to noise waveforms';
309 | }
310 | },
311 | });
312 | this.vibratoDepth = new Parameter({
313 | label: 'Vibrato depth',
314 | description: 'Amount by which to vibrate around the base frequency.',
315 | unit: 'Hz',
316 | defaultValue: 0,
317 | minValue: 0,
318 | maxValue: 1000,
319 | step: 10,
320 | logarithmic: true,
321 | });
322 | this.vibratoFrequency = new Parameter({
323 | label: 'Vibrato frequency',
324 | description: 'Number of times per second to vibrate around the base frequency.',
325 | unit: 'Hz',
326 | defaultValue: 10,
327 | minValue: 0,
328 | maxValue: 1000,
329 | step: 1,
330 | logarithmic: true,
331 | });
332 | this.squareDuty = new Parameter({
333 | label: 'Square duty',
334 | description: 'For square waves only, the initial fraction of time the square is in the "on" state.',
335 | unit: '%',
336 | defaultValue: 50,
337 | minValue: 0,
338 | maxValue: 100,
339 | step: 5,
340 | disabledReason: isNotSquare,
341 | });
342 | this.squareDutySweep = new Parameter({
343 | label: 'Square duty sweep',
344 | description: 'For square waves only, change the square duty linearly by this many percentage points over the course of the sound.',
345 | unit: '%',
346 | defaultValue: 0,
347 | minValue: -100,
348 | maxValue: 100,
349 | step: 5,
350 | disabledReason: isNotSquare,
351 | });
352 |
353 | // Filter parameters
354 |
355 | this.flangerOffset = new Parameter({
356 | label: 'Flanger offset',
357 | description: 'The initial offset for the flanger effect. Mixes the sound with itself, delayed initially by this amount.',
358 | unit: 'ms',
359 | defaultValue: 0,
360 | minValue: 0,
361 | maxValue: 50,
362 | step: 1,
363 | });
364 | this.flangerOffsetSweep = new Parameter({
365 | label: 'Flanger offset sweep',
366 | description: 'Amount by which the flanger offset changes linearly over the course of the sound.',
367 | unit: 'ms',
368 | defaultValue: 0,
369 | minValue: -50,
370 | maxValue: 50,
371 | step: 1,
372 | });
373 | this.bitCrush = new Parameter({
374 | label: 'Bit crush',
375 | description: 'Number of bits per sample. Reduces the number of bits in each sample by this amount, and then increase it again. The result is a lower-fidelity sound effect.',
376 | unit: 'bits',
377 | defaultValue: 16,
378 | minValue: 1,
379 | maxValue: 16,
380 | step: 1,
381 | });
382 | this.bitCrushSweep = new Parameter({
383 | label: 'Bit crush sweep',
384 | description: 'Amount by which to change the bit crush value linearly over the course of the sound.',
385 | unit: 'bits',
386 | defaultValue: 0,
387 | minValue: -16,
388 | maxValue: 16,
389 | step: 1,
390 | });
391 | this.lowPassCutoff = new Parameter({
392 | label: 'Low-pass cutoff',
393 | description: 'Threshold above which frequencies should be filtered out, using a simple IIR low-pass filter. Use this to take some "edge" off the sound.',
394 | unit: 'Hz',
395 | defaultValue: 22050,
396 | minValue: 0,
397 | maxValue: 22050,
398 | step: 100,
399 | logarithmic: true,
400 | });
401 | this.lowPassCutoffSweep = new Parameter({
402 | label: 'Low-pass sweep',
403 | description: 'Amount by which to change the low-pass cutoff frequency over the course of the sound.',
404 | unit: 'Hz',
405 | defaultValue: 0,
406 | minValue: -22050,
407 | maxValue: 22050,
408 | step: 100,
409 | logarithmic: true,
410 | });
411 | this.highPassCutoff = new Parameter({
412 | label: 'High-pass cutoff',
413 | description: 'Threshold below which frequencies should be filtered out, using a simple high-pass filter.',
414 | unit: 'Hz',
415 | defaultValue: 0,
416 | minValue: 0,
417 | maxValue: 22050,
418 | step: 100,
419 | logarithmic: true,
420 | });
421 | this.highPassCutoffSweep = new Parameter({
422 | label: 'High-pass sweep',
423 | description: 'Amount by which to change the high-pass cutoff frequency over the course of the sound.',
424 | unit: 'Hz',
425 | defaultValue: 0,
426 | minValue: -22050,
427 | maxValue: 22050,
428 | step: 100,
429 | logarithmic: true,
430 | });
431 |
432 | // Output parameters
433 |
434 | this.compression = new Parameter({
435 | label: 'Compression',
436 | description: 'Power to which sample values should be raised. 1 is the neutral setting. Use a value less than 1 to increase the volume of quiet parts of the sound, higher than 1 to make quiet parts even quieter.',
437 | defaultValue: 1,
438 | minValue: 0,
439 | maxValue: 5,
440 | step: 0.1,
441 | });
442 | this.normalization = new Parameter({
443 | label: 'Normalization',
444 | description: 'Whether to adjust the volume of the sound so that the peak volume is at 100%.',
445 | type: 'boolean',
446 | defaultValue: true,
447 | });
448 | this.amplification = new Parameter({
449 | label: 'Amplification',
450 | description: 'Percentage to amplify the sound by, after any normalization has occurred. Note that setting this too high can result in clipping.',
451 | unit: '%',
452 | defaultValue: 100,
453 | minValue: 0,
454 | maxValue: 500,
455 | step: 10,
456 | });
457 | };
458 |
459 | Sound.prototype.duration = function() {
460 | return this.attack.value + this.sustain.value + this.decay.value;
461 | };
462 |
463 | Sound.prototype.amplitudeAt = function(time) {
464 | var attack = this.attack.value;
465 | var sustain = this.sustain.value;
466 | var sustainPunch = this.sustainPunch.value;
467 | var decay = this.decay.value;
468 | var tremoloDepth = this.tremoloDepth.value;
469 | var amp;
470 | if (time < attack) {
471 | amp = time / attack;
472 | } else if (time < attack + sustain) {
473 | amp = 1 + sustainPunch / 100 * (1 - (time - attack) / sustain);
474 | } else if (time < attack + sustain + decay) {
475 | amp = 1 - (time - attack - sustain) / decay;
476 | } else { // This can happen due to roundoff error because the sample count is an integer.
477 | amp = 0;
478 | }
479 | if (tremoloDepth !== 0) {
480 | amp *= 1 - (tremoloDepth / 100) * (0.5 + 0.5 * Math.cos(2 * Math.PI * time * this.tremoloFrequency.value));
481 | }
482 | return amp;
483 | };
484 |
485 | Sound.prototype.effectiveRepeatFrequency = function() {
486 | return Math.max(this.repeatFrequency.value, 1 / this.duration());
487 | };
488 |
489 | Sound.prototype.frequencyAt = function(time) {
490 | var repeatFrequency = this.effectiveRepeatFrequency();
491 | var fractionInRepetition = frac(time * repeatFrequency);
492 | var freq =
493 | this.frequency.value +
494 | fractionInRepetition * this.frequencySweep.value +
495 | fractionInRepetition * fractionInRepetition * this.frequencyDeltaSweep.value;
496 | if (fractionInRepetition > this.frequencyJump1Onset.value / 100) {
497 | freq *= 1 + this.frequencyJump1Amount.value / 100;
498 | }
499 | if (fractionInRepetition > this.frequencyJump2Onset.value / 100) {
500 | freq *= 1 + this.frequencyJump2Amount.value / 100;
501 | }
502 | if (this.vibratoDepth.value !== 0) {
503 | freq += 1 - this.vibratoDepth.value * (0.5 - 0.5 * Math.sin(2 * Math.PI * time * this.vibratoFrequency.value));
504 | }
505 | return Math.max(0, freq);
506 | };
507 |
508 | Sound.prototype.squareDutyAt = function(time) {
509 | var repeatFrequency = this.effectiveRepeatFrequency();
510 | var fractionInRepetition = frac(time * repeatFrequency);
511 | return (this.squareDuty.value + fractionInRepetition * this.squareDutySweep.value) / 100;
512 | };
513 |
514 | Sound.prototype.forEachParam = function(func) {
515 | for (var key in this) {
516 | var value = this[key];
517 | if (value instanceof Parameter) {
518 | func(key, value);
519 | }
520 | }
521 | };
522 |
523 | Sound.prototype.reset = function() {
524 | this.forEachParam(function(key, param) {
525 | param.reset();
526 | param.locked = false;
527 | });
528 | };
529 |
530 | Sound.prototype.clone = function() {
531 | var clone = new Sound();
532 | clone.parse(this.serialize());
533 | return clone;
534 | };
535 |
536 | Sound.prototype.serialize = function() {
537 | var json = {
538 | _version: 1,
539 | _name: this.name,
540 | _locked: [],
541 | };
542 | this.forEachParam(function(key, param) {
543 | json[key] = param.value;
544 | if (param.locked) {
545 | json._locked.push(key);
546 | }
547 | });
548 | return JSON.stringify(json);
549 | };
550 |
551 | Sound.prototype.parse = function(str) {
552 | this.reset();
553 | if (str && str !== '') {
554 | var json = JSON.parse(str);
555 | if (json._version > VERSION) {
556 | throw new Error('Cannot read this sound; it was written by jfxr version ' + json._version +
557 | ' but we support only up to version ' + VERSION + '. Please update the jfxr library.');
558 | }
559 |
560 | this.name = json._name || 'Unnamed';
561 | this.forEachParam(function(key, param) {
562 | if (key in json) {
563 | param.value = json[key];
564 | }
565 | });
566 |
567 | var locked = json._locked || [];
568 | for (var i = 0; i < locked.length; i++) {
569 | var param = this[locked[i]];
570 | if (param instanceof Parameter) {
571 | param.locked = true;
572 | }
573 | }
574 | }
575 | };
576 |
--------------------------------------------------------------------------------
/lib/src/synth.js:
--------------------------------------------------------------------------------
1 | import { clamp, frac, lerp } from './math.js';
2 | import { Clip } from './clip.js';
3 | import { Random } from './random.js';
4 | import { Sound } from './sound.js';
5 |
6 | /**
7 | * This class synthesizes sound effects. It is intended for one-shot use, so do
8 | * not try to use a single instance multiple times.
9 | *
10 | * Example usage:
11 | *
12 | * var json = '{...}'; // E.g. contents of a .jfxr file.
13 | * var synth = new Synth(json);
14 | * synth.run(function(clip) {
15 | * var samples = sound.array; // raw samples as a Float32Array
16 | * var sampleRate = sound.sampleRate; // sample rate in Hz
17 | * });
18 | *
19 | * @param {function} setTimeout A function that can be called in the same way
20 | * as window.setTimeout (remember to bind() it or use a fat arrow if
21 | * needed). If not provided, window.setTimeout will be used directly.
22 | * @param {string} json A string containing a serialized Sound.
23 | */
24 | export var Synth = function(json, setTimeout) {
25 | this.setTimeout = setTimeout ||
26 | (typeof window !== 'undefined' && window.setTimeout && window.setTimeout.bind(window)) || // eslint-disable-line no-undef
27 | (typeof global !== 'undefined' && global.setTimeout && global.setTimeout.bind(global)); // eslint-disable-line no-undef
28 | this.sound = new Sound();
29 | this.sound.parse(json);
30 |
31 | var sampleRate = this.sound.sampleRate.value;
32 |
33 | var numSamples = Math.max(1, Math.ceil(sampleRate * this.sound.duration()));
34 |
35 | this.array = new Float32Array(numSamples);
36 |
37 | var classes = [
38 | Synth.Generator,
39 | Synth.Envelope,
40 | Synth.Flanger,
41 | Synth.BitCrush,
42 | Synth.LowPass,
43 | Synth.HighPass,
44 | Synth.Compress,
45 | Synth.Normalize,
46 | Synth.Amplify,
47 | ];
48 |
49 | this.transformers = [];
50 | for (var i = 0; i < classes.length; i++) {
51 | this.transformers.push(new classes[i](this.sound, this.array));
52 | }
53 |
54 | this.startTime = Date.now();
55 |
56 | this.startSample = 0;
57 | this.blockSize = 10240;
58 | };
59 |
60 | /**
61 | * @param {function} doneCallback A callback that is invoked when the synthesis
62 | * is complete. It receives one argument, which is a Clip object.
63 | */
64 | Synth.prototype.run = function(doneCallback) {
65 | if (this.doneCallback) {
66 | return;
67 | }
68 | this.doneCallback = doneCallback;
69 | this.tick();
70 | };
71 |
72 | /**
73 | * @return {bool} True if the synth is currently running (between a call to
74 | * run() and either cancel() or receipt of a doneCallback() call).
75 | */
76 | Synth.prototype.isRunning = function() {
77 | return !!this.doneCallback;
78 | };
79 |
80 | /**
81 | * Cancels synthesis if currently running.
82 | */
83 | Synth.prototype.cancel = function() {
84 | if (!this.isRunning()) {
85 | return;
86 | }
87 | this.doneCallback = null;
88 | };
89 |
90 | /**
91 | * @private
92 | */
93 | Synth.prototype.tick = function() {
94 | if (!this.isRunning()) {
95 | return;
96 | }
97 |
98 | var numSamples = this.array.length;
99 | var endSample = Math.min(numSamples, this.startSample + this.blockSize);
100 | for (var i = 0; i < this.transformers.length; i++) {
101 | this.transformers[i].run(this.sound, this.array, this.startSample, endSample);
102 | }
103 | this.startSample = endSample;
104 |
105 | if (this.startSample == numSamples) {
106 | this.renderTimeMs = Date.now() - this.startTime;
107 | // Always invoke the callback from a timeout so that, in case setTimeout is
108 | // $timeout, Angular will run a digest after it.
109 | this.setTimeout(function() {
110 | if (this.doneCallback) {
111 | this.doneCallback(new Clip(this.array, this.sound.sampleRate.value));
112 | this.doneCallback = null;
113 | }
114 | }.bind(this));
115 | } else {
116 | // TODO be smarter about block size (sync with animation frames)
117 | // window.requestAnimationFrame(this.tick.bind(this));
118 | this.tick();
119 | }
120 | };
121 |
122 | Synth.Generator = function(sound, unused_array) {
123 | var oscillatorClass = {
124 | sine: Synth.SineOscillator,
125 | triangle: Synth.TriangleOscillator,
126 | sawtooth: Synth.SawtoothOscillator,
127 | square: Synth.SquareOscillator,
128 | tangent: Synth.TangentOscillator,
129 | whistle: Synth.WhistleOscillator,
130 | breaker: Synth.BreakerOscillator,
131 | whitenoise: Synth.WhiteNoiseOscillator,
132 | pinknoise: Synth.PinkNoiseOscillator,
133 | brownnoise: Synth.BrownNoiseOscillator,
134 | }[sound.waveform.value];
135 |
136 | var amp = 1;
137 | var totalAmp = 0;
138 | this.oscillators = [];
139 | for (var harmonicIndex = 0; harmonicIndex <= sound.harmonics.value; harmonicIndex++) {
140 | totalAmp += amp;
141 | amp *= sound.harmonicsFalloff.value;
142 | this.oscillators.push(new oscillatorClass(sound));
143 | }
144 | this.firstHarmonicAmp = 1 / totalAmp;
145 |
146 | this.phase = 0;
147 | this.prevPhase = 0;
148 | };
149 |
150 | Synth.Generator.prototype.run = function(sound, array, startSample, endSample) {
151 | var sampleRate = sound.sampleRate.value;
152 | var harmonics = sound.harmonics.value;
153 | var harmonicsFalloff = sound.harmonicsFalloff.value;
154 |
155 | var firstHarmonicAmp = this.firstHarmonicAmp;
156 | var oscillators = this.oscillators;
157 |
158 | var phase = this.phase;
159 |
160 | for (var i = startSample; i < endSample; i++) {
161 | var time = i / sampleRate;
162 |
163 | var currentFrequency = sound.frequencyAt(time);
164 | phase = frac(phase + currentFrequency / sampleRate);
165 |
166 | var sample = 0;
167 | var amp = firstHarmonicAmp;
168 | for (var harmonicIndex = 0; harmonicIndex <= harmonics; harmonicIndex++) {
169 | var harmonicPhase = frac(phase * (harmonicIndex + 1));
170 | sample += amp * oscillators[harmonicIndex].getSample(harmonicPhase, time);
171 | amp *= harmonicsFalloff;
172 | }
173 | array[i] = sample;
174 | }
175 |
176 | this.phase = phase;
177 | };
178 |
179 | Synth.SineOscillator = function() {};
180 | Synth.SineOscillator.prototype.getSample = function(phase) {
181 | return Math.sin(2 * Math.PI * phase);
182 | };
183 |
184 | Synth.TriangleOscillator = function() {};
185 | Synth.TriangleOscillator.prototype.getSample = function(phase) {
186 | if (phase < 0.25) return 4 * phase;
187 | if (phase < 0.75) return 2 - 4 * phase;
188 | return -4 + 4 * phase;
189 | };
190 |
191 | Synth.SawtoothOscillator = function() {};
192 | Synth.SawtoothOscillator.prototype.getSample = function(phase) {
193 | return phase < 0.5 ? 2 * phase : -2 + 2 * phase;
194 | };
195 |
196 | Synth.SquareOscillator = function(sound) {
197 | this.sound = sound;
198 | };
199 | Synth.SquareOscillator.prototype.getSample = function(phase, time) {
200 | return phase < this.sound.squareDutyAt(time) ? 1 : -1;
201 | };
202 |
203 | Synth.TangentOscillator = function() {};
204 | Synth.TangentOscillator.prototype.getSample = function(phase) {
205 | // Arbitrary cutoff value to make normalization behave.
206 | return clamp(-2, 2, 0.3 * Math.tan(Math.PI * phase));
207 | };
208 |
209 | Synth.WhistleOscillator = function() {};
210 | Synth.WhistleOscillator.prototype.getSample = function(phase) {
211 | return 0.75 * Math.sin(2 * Math.PI * phase) + 0.25 * Math.sin(40 * Math.PI * phase);
212 | };
213 |
214 | Synth.BreakerOscillator = function() {};
215 | Synth.BreakerOscillator.prototype.getSample = function(phase) {
216 | // Make sure to start at a zero crossing.
217 | var p = frac(phase + Math.sqrt(0.75));
218 | return -1 + 2 * Math.abs(1 - p*p*2);
219 | };
220 |
221 | Synth.WhiteNoiseOscillator = function(sound) {
222 | this.interpolateNoise = sound.interpolateNoise.value;
223 |
224 | this.random = new Random(0x3cf78ba3);
225 | this.prevPhase = 0;
226 | this.prevRandom = 0;
227 | this.currRandom = 0;
228 | };
229 | Synth.WhiteNoiseOscillator.prototype.getSample = function(phase) {
230 | // Need two samples per phase in order to include the desired frequencies.
231 | phase = frac(phase * 2);
232 | if (phase < this.prevPhase) {
233 | this.prevRandom = this.currRandom;
234 | this.currRandom = this.random.uniform(-1, 1);
235 | }
236 | this.prevPhase = phase;
237 |
238 | return this.interpolateNoise ?
239 | lerp(this.prevRandom, this.currRandom, phase) :
240 | this.currRandom;
241 | };
242 |
243 | Synth.PinkNoiseOscillator = function(sound, unused_array) {
244 | this.interpolateNoise = sound.interpolateNoise.value;
245 |
246 | this.random = new Random(0x3cf78ba3);
247 | this.prevPhase = 0;
248 | this.b = [0, 0, 0, 0, 0, 0, 0];
249 | this.prevRandom = 0;
250 | this.currRandom = 0;
251 | };
252 | Synth.PinkNoiseOscillator.prototype.getSample = function(phase) {
253 | // Need two samples per phase in order to include the desired frequencies.
254 | phase = frac(phase * 2);
255 | if (phase < this.prevPhase) {
256 | this.prevRandom = this.currRandom;
257 | // Method pk3 from http://www.firstpr.com.au/dsp/pink-noise/,
258 | // due to Paul Kellet.
259 | var white = this.random.uniform(-1, 1);
260 | this.b[0] = 0.99886 * this.b[0] + white * 0.0555179;
261 | this.b[1] = 0.99332 * this.b[1] + white * 0.0750759;
262 | this.b[2] = 0.96900 * this.b[2] + white * 0.1538520;
263 | this.b[3] = 0.86650 * this.b[3] + white * 0.3104856;
264 | this.b[4] = 0.55000 * this.b[4] + white * 0.5329522;
265 | this.b[5] = -0.7616 * this.b[5] + white * 0.0168980;
266 | this.currRandom = (this.b[0] + this.b[1] + this.b[2] + this.b[3] + this.b[4] + this.b[5] + this.b[6] + white * 0.5362) / 7;
267 | this.b[6] = white * 0.115926;
268 | }
269 | this.prevPhase = phase;
270 |
271 | return this.interpolateNoise ?
272 | lerp(this.prevRandom, this.currRandom, phase) :
273 | this.currRandom;
274 | };
275 |
276 | Synth.BrownNoiseOscillator = function(sound, unused_array) {
277 | this.interpolateNoise = sound.interpolateNoise.value;
278 |
279 | this.random = new Random(0x3cf78ba3);
280 | this.prevPhase = 0;
281 | this.prevRandom = 0;
282 | this.currRandom = 0;
283 | };
284 | Synth.BrownNoiseOscillator.prototype.getSample = function(phase) {
285 | // Need two samples per phase in order to include the desired frequencies.
286 | phase = frac(phase * 2);
287 | if (phase < this.prevPhase) {
288 | this.prevRandom = this.currRandom;
289 | this.currRandom = clamp(-1, 1, this.currRandom + 0.1 * this.random.uniform(-1, 1));
290 | }
291 | this.prevPhase = phase;
292 |
293 | return this.interpolateNoise ?
294 | lerp(this.prevRandom, this.currRandom, phase) :
295 | this.currRandom;
296 | };
297 |
298 | Synth.Flanger = function(sound, unused_array) {
299 | if (sound.flangerOffset.value === 0 && sound.flangerOffsetSweep.value === 0) {
300 | return;
301 | }
302 |
303 | // Maximum 100ms offset
304 | this.buffer = new Float32Array(Math.ceil(sound.sampleRate.value * 0.1));
305 | this.bufferPos = 0;
306 | };
307 |
308 | Synth.Flanger.prototype.run = function(sound, array, startSample, endSample) {
309 | if (!this.buffer) {
310 | return;
311 | }
312 |
313 | var numSamples = array.length;
314 | var sampleRate = sound.sampleRate.value;
315 | var flangerOffset = sound.flangerOffset.value;
316 | var flangerOffsetSweep = sound.flangerOffsetSweep.value;
317 |
318 | var buffer = this.buffer;
319 | var bufferPos = this.bufferPos;
320 | var bufferLength = buffer.length;
321 |
322 | for (var i = startSample; i < endSample; i++) {
323 | buffer[bufferPos] = array[i];
324 |
325 | var offsetSamples = Math.round((flangerOffset + i / numSamples * flangerOffsetSweep) / 1000 * sampleRate);
326 | offsetSamples = clamp(0, bufferLength - 1, offsetSamples);
327 | array[i] += buffer[(bufferPos - offsetSamples + bufferLength) % bufferLength];
328 | bufferPos = (bufferPos + 1) % bufferLength;
329 | }
330 |
331 | this.bufferPos = bufferPos;
332 | };
333 |
334 | Synth.BitCrush = function(unused_sound, unused_array) {
335 | };
336 |
337 | Synth.BitCrush.prototype.run = function(sound, array, startSample, endSample) {
338 | var numSamples = array.length;
339 | var bitCrush = sound.bitCrush.value;
340 | var bitCrushSweep = sound.bitCrushSweep.value;
341 |
342 | if (bitCrush === 0 && bitCrushSweep === 0) {
343 | return;
344 | }
345 |
346 | for (var i = startSample; i < endSample; i++) {
347 | var bits = bitCrush + i / numSamples * bitCrushSweep;
348 | bits = clamp(1, 16, Math.round(bits));
349 | var steps = Math.pow(2, bits);
350 | array[i] = -1 + 2 * Math.round((0.5 + 0.5 * array[i]) * steps) / steps;
351 | }
352 | };
353 |
354 | Synth.LowPass = function(unused_sound, unused_array) {
355 | this.lowPassPrev = 0;
356 | };
357 |
358 | Synth.LowPass.prototype.run = function(sound, array, startSample, endSample) {
359 | var numSamples = array.length;
360 | var lowPassCutoff = sound.lowPassCutoff.value;
361 | var lowPassCutoffSweep = sound.lowPassCutoffSweep.value;
362 | var sampleRate = sound.sampleRate.value;
363 |
364 | if (lowPassCutoff >= sampleRate / 2 && lowPassCutoff + lowPassCutoffSweep >= sampleRate / 2) {
365 | return;
366 | }
367 |
368 | var lowPassPrev = this.lowPassPrev;
369 |
370 | for (var i = startSample; i < endSample; i++) {
371 | var fraction = i / numSamples;
372 | var cutoff = clamp(0, sampleRate / 2, lowPassCutoff + fraction * lowPassCutoffSweep);
373 | var wc = cutoff / sampleRate * Math.PI; // Don't we need a factor 2pi instead of pi?
374 | var cosWc = Math.cos(wc);
375 | var lowPassAlpha;
376 | if (cosWc <= 0) {
377 | lowPassAlpha = 1;
378 | } else {
379 | // From somewhere on the internet: cos wc = 2a / (1+a^2)
380 | lowPassAlpha = 1 / cosWc - Math.sqrt(1 / (cosWc * cosWc) - 1);
381 | lowPassAlpha = 1 - lowPassAlpha; // Probably the internet's definition of alpha is different.
382 | }
383 | var sample = array[i];
384 | sample = lowPassAlpha * sample + (1 - lowPassAlpha) * lowPassPrev;
385 | lowPassPrev = sample;
386 | array[i] = sample;
387 | }
388 |
389 | this.lowPassPrev = lowPassPrev;
390 | };
391 |
392 | Synth.HighPass = function(unused_sound, unused_array) {
393 | this.highPassPrevIn = 0;
394 | this.highPassPrevOut = 0;
395 | };
396 |
397 | Synth.HighPass.prototype.run = function(sound, array, startSample, endSample) {
398 | var numSamples = array.length;
399 | var sampleRate = sound.sampleRate.value;
400 | var highPassCutoff = sound.highPassCutoff.value;
401 | var highPassCutoffSweep = sound.highPassCutoffSweep.value;
402 |
403 | if (highPassCutoff <= 0 && highPassCutoff + highPassCutoffSweep <= 0) {
404 | return;
405 | }
406 |
407 | var highPassPrevIn = this.highPassPrevIn;
408 | var highPassPrevOut = this.highPassPrevOut;
409 |
410 | for (var i = startSample; i < endSample; i++) {
411 | var fraction = i / numSamples;
412 | var cutoff = clamp(0, sampleRate / 2, highPassCutoff + fraction * highPassCutoffSweep);
413 | var wc = cutoff / sampleRate * Math.PI;
414 | // From somewhere on the internet: a = (1 - sin wc) / cos wc
415 | var highPassAlpha = (1 - Math.sin(wc)) / Math.cos(wc);
416 | var sample = array[i];
417 | var origSample = sample;
418 | sample = highPassAlpha * (highPassPrevOut - highPassPrevIn + sample);
419 | highPassPrevIn = origSample;
420 | highPassPrevOut = sample;
421 | array[i] = sample;
422 | }
423 |
424 | this.highPassPrevIn = highPassPrevIn;
425 | this.highPassPrevOut = highPassPrevOut;
426 | };
427 |
428 | Synth.Envelope = function(unused_sound, unused_array) {
429 | };
430 |
431 | Synth.Envelope.prototype.run = function(sound, array, startSample, endSample) {
432 | var sampleRate = sound.sampleRate.value;
433 | var attack = sound.attack.value;
434 | var sustainPunch = sound.sustainPunch.value;
435 | var decay = sound.decay.value;
436 | var tremoloDepth = sound.tremoloDepth.value;
437 |
438 | if (attack === 0 && sustainPunch == 0 && decay === 0 && tremoloDepth === 0) {
439 | return;
440 | }
441 |
442 | for (var i = startSample; i < endSample; i++) {
443 | var time = i / sampleRate;
444 | array[i] *= sound.amplitudeAt(time);
445 | }
446 | };
447 |
448 | Synth.Compress = function(unused_sound, unused_array) {
449 | };
450 |
451 | Synth.Compress.prototype.run = function(sound, array, startSample, endSample) {
452 | var compression = sound.compression.value;
453 |
454 | if (compression == 1) {
455 | return;
456 | }
457 |
458 | for (var i = startSample; i < endSample; i++) {
459 | var sample = array[i];
460 | if (sample >= 0) {
461 | sample = Math.pow(sample, compression);
462 | } else {
463 | sample = -Math.pow(-sample, compression);
464 | }
465 | array[i] = sample;
466 | }
467 | };
468 |
469 | Synth.Normalize = function(unused_sound, unused_array) {
470 | this.maxSample = 0;
471 | };
472 |
473 | Synth.Normalize.prototype.run = function(sound, array, startSample, endSample) {
474 | if (!sound.normalization.value) {
475 | return;
476 | }
477 |
478 | var maxSample = this.maxSample;
479 | var i;
480 | for (i = startSample; i < endSample; i++) {
481 | maxSample = Math.max(maxSample, Math.abs(array[i]));
482 | }
483 | this.maxSample = maxSample;
484 |
485 | var numSamples = array.length;
486 | if (endSample == numSamples) {
487 | var factor = 1 / maxSample;
488 | for (i = 0; i < numSamples; i++) {
489 | array[i] *= factor;
490 | }
491 | }
492 | };
493 |
494 | Synth.Amplify = function(unused_sound, unused_array) {
495 | };
496 |
497 | Synth.Amplify.prototype.run = function(sound, array, startSample, endSample) {
498 | var factor = sound.amplification.value / 100;
499 |
500 | if (factor == 1) {
501 | return;
502 | }
503 |
504 | for (var i = startSample; i < endSample; i++) {
505 | array[i] *= factor;
506 | }
507 | };
508 |
--------------------------------------------------------------------------------
/lib/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | const ESLintPlugin = require('eslint-webpack-plugin');
4 |
5 | module.exports = {
6 | mode: 'production',
7 | entry: './index.js',
8 | output: {
9 | path: path.resolve(__dirname, 'dist'),
10 | filename: 'jfxr.min.js',
11 | libraryTarget: 'umd',
12 | library: 'jfxr',
13 | },
14 | devtool: 'source-map',
15 | plugins: [
16 | new ESLintPlugin({
17 | emitWarning: true,
18 | }),
19 | ],
20 | stats: 'minimal',
21 | };
22 |
--------------------------------------------------------------------------------