├── .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 | 21 | 41 | 48 | 58 | 59 | 61 | 68 | 74 | 75 | 82 | 88 | 89 | 96 | 102 | 103 | 110 | 116 | 117 | 124 | 130 | 131 | 138 | 144 | 145 | 146 | 148 | 149 | 151 | image/svg+xml 152 | 154 | 155 | 156 | 157 | 158 | 173 | 179 | 187 | 193 | 199 | 205 | 211 | 217 | 223 | 229 | 235 | 240 | 248 | 254 | 259 | 264 | 271 | 284 | 291 | 297 | 303 | 316 | 322 | 328 | 341 | 348 | 349 | 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 |
23 |

jfxr ~

24 |

by @frozenfractal

25 |
26 | 27 |
28 | 29 | 33 | 36 |
37 | 38 |
39 | 40 |
41 | 42 |
43 | 44 |
45 | 48 | 51 | 54 | 58 | 61 |
62 |
63 | 67 |
68 | 71 |
72 |
73 |
74 | 79 |
80 |
    81 |
  • 82 | 83 |
    84 | 85 |
  • 86 |
87 |
88 | Rendering… 89 | Render time: {{ctrl.synth.renderTimeMs}} ms 90 |
91 |
92 | 93 |
94 | 95 |
96 |
97 |

Amplitude

98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 |

Pitch

107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 |
119 |
120 |

Tone

121 | 122 |
123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 |
134 |
135 | {{ctrl.getSound().waveform.valueTitle()}} 136 |
137 |
138 | 139 | 140 | 141 | 142 | 143 | 144 |

Filters

145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 |

Output

155 | 156 | 157 | 158 |
159 |
160 |
161 |

162 | {{ctrl.hoveredParam.label}}: {{ctrl.hoveredParam.description}} 163 |

164 |

165 |   166 |

167 |
168 |
169 | FAQ | 170 | Source code | 171 | Issue tracker | 172 | Web | 173 | Twitter | 174 | Email 175 |
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 | --------------------------------------------------------------------------------