├── .gitignore ├── LICENSE.md ├── README.md └── planets ├── README.md ├── Synth.js ├── app.js ├── drawing.js ├── favicon.ico ├── genSynth.js ├── index.html ├── package-lock.json ├── package.json └── styles.css /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | node_modules 4 | 5 | .cache 6 | .parcel-cache 7 | 8 | *.swp 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2022 Elementary Audio, LLC 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 14 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 | PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Web Rendering Examples 2 | 3 | This repository holds a set of small examples (well, currently just one) using 4 | Elementary Audio in the web browser to build audio applications. Each 5 | subdirectory is itself a standalone web app; step into any given app 6 | subdirectory and check the README for specific instructions or details. 7 | 8 | If you're new to Elementary Audio, [**Elementary**](https://elementary.audio) is a JavaScript/C++ library for building audio applications. 9 | 10 | * **Declarative:** Elementary makes it simple to create interactive audio processes through functional, declarative programming. Describe your audio process as a function of your application state, and Elementary will efficiently update the underlying audio engine as necessary. 11 | * **Dynamic:** Most audio processing frameworks and tools facilitate building static processes. But what happens as your audio requirements change throughout the user journey? Elementary is designed to facilitate and adapt to the dynamic nature of modern audio applications. 12 | * **Portable:** By decoupling the JavaScript API from the underlying audio engine (the "what" from the "how"), Elementary enables writing portable applications. Whether the underlying engine is running in the browser, an audio plugin, or an embedded device, the JavaScript layer remains the same. 13 | 14 | Find more in the [Elementary repository on GitHub](https://github.com/elemaudio/elementary) and the documentation [on the website](https://elementary.audio/). 15 | 16 | ## Examples 17 | 18 | * [Planets](https://github.com/elemaudio/web-examples/tree/master/planets) 19 | * More to come... 20 | 21 | ## License 22 | 23 | [ISC](LICENSE.md) 24 | -------------------------------------------------------------------------------- /planets/README.md: -------------------------------------------------------------------------------- 1 | # Planets 2 | 3 | A real-time generative melody played through a simple sine tone bell synthesizer into 4 | a lush reverb. The synth output is measured by an oscilloscope, which is then plotted as 5 | Lissajous curves wrapped around the surface of a slowly rotating sphere. 6 | 7 | ## Live Demo 8 | 9 | * [https://elemaudio.github.io/web-examples/](https://elemaudio.github.io/web-examples/) 10 | 11 | ## Running locally 12 | 13 | This app is a simple web app built with [Parcel](https://parceljs.org/), which means getting 14 | up and running is easy: 15 | 16 | * Clone the repository 17 | * `cd planets && npm install` 18 | * `npm start` to run the local development server 19 | * `npm run build` to bundle production assets 20 | -------------------------------------------------------------------------------- /planets/Synth.js: -------------------------------------------------------------------------------- 1 | // A simple polyphonic Synth which records note events against internal 2 | // sequence data. 3 | // 4 | // After recording a series of events, call the render function to build 5 | // a series of synth voices based on the aggregated sequence data. 6 | export default class Synth { 7 | constructor(key, numVoices) { 8 | this.voices = Array.from({length: numVoices}).map(function(x, i) { 9 | return { 10 | key: `${key}:v:${i}`, 11 | gate: 0, 12 | freq: 440, 13 | gain: 0, 14 | data: { 15 | gateSeq: [], 16 | freqSeq: [], 17 | veloSeq: [], 18 | } 19 | }; 20 | }); 21 | 22 | this.nextVoice = 0; 23 | } 24 | 25 | reset() { 26 | this.voices.forEach(function(v) { 27 | v.gate = 0; 28 | v.freq = 440; 29 | v.gain = 1; 30 | v.data.gateSeq.length = 0; 31 | v.data.freqSeq.length = 0; 32 | v.data.veloSeq.length = 0; 33 | }) 34 | 35 | this.nextVoice = 0; 36 | } 37 | 38 | step() { 39 | this.voices.forEach(function(voice, i) { 40 | voice.data.gateSeq.push(voice.gate); 41 | voice.data.freqSeq.push(voice.freq); 42 | voice.data.veloSeq.push(voice.gain); 43 | }) 44 | } 45 | 46 | noteOn(freq, gain) { 47 | this.voices[this.nextVoice].gate = 1.0; 48 | this.voices[this.nextVoice].freq = freq; 49 | this.voices[this.nextVoice].gain = gain; 50 | 51 | if (++this.nextVoice >= this.voices.length) { 52 | this.nextVoice -= this.voices.length; 53 | } 54 | } 55 | 56 | noteOff(freq) { 57 | for (let i = 0; i < this.voices.length; ++i) { 58 | if (this.voices[i].freq === freq) { 59 | this.voices[i].gate = 0; 60 | } 61 | } 62 | } 63 | 64 | render(renderFunc) { 65 | return this.voices.map(function(voice, i) { 66 | return renderFunc(voice.key, voice.data.gateSeq, voice.data.freqSeq, voice.data.veloSeq, i); 67 | }); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /planets/app.js: -------------------------------------------------------------------------------- 1 | import WebRenderer from '@elemaudio/web-renderer'; 2 | import {el} from '@elemaudio/core'; 3 | 4 | import genSynth from './genSynth'; 5 | import {clear, draw} from './drawing'; 6 | 7 | 8 | // Constants 9 | const audioContext = new (window.AudioContext || window.webkitAudioContext)(); 10 | const core = new WebRenderer(); 11 | const canvas = document.getElementById('canvas'); 12 | const clone = canvas.cloneNode(); 13 | const ctx = canvas.getContext('2d'); 14 | const cloneCtx = clone.getContext('2d'); 15 | const globalAlpha = 0.94 + (Math.random() * 0.05); 16 | 17 | let width = canvas.width = clone.width = window.innerWidth; 18 | let height = canvas.height = clone.height = window.innerHeight; 19 | 20 | // Must be set after the assignment to width/height, which resets the 21 | // state of the canvas contexts 22 | cloneCtx.globalAlpha = globalAlpha; 23 | 24 | // Our mutable app state 25 | const state = { 26 | frameCount: 0, 27 | channelData: [ 28 | (new Float32Array(512)).map((x, i) => Math.sin(i / 512 * 1 * Math.PI)), 29 | (new Float32Array(512)).map((x, i) => Math.cos(i / 512 * 1 * Math.PI)), 30 | ], 31 | }; 32 | 33 | // Our main audio rendering step 34 | core.on('load', function(e) { 35 | const bpm = 76; 36 | const n64Rate = 1 / ((60 / bpm) / 8); 37 | const adsrDecay = 3.5; 38 | const [syn1, syn2] = genSynth(); 39 | 40 | let ll = el.add(...syn1.render(function(key, gs, fs, vs, i) { 41 | let t = el.train(n64Rate); 42 | let env = el.adsr(0.01, adsrDecay, 0, adsrDecay, el.seq({key: `${key}:gs`, seq: gs, hold: true}, t, 0)); 43 | let gain = el.seq({key: `${key}:vs`, seq: vs, hold: true}, t, 0); 44 | 45 | return el.mul(env, el.sm(gain), el.cycle(el.smooth(el.tau2pole(0.01), el.seq({key: `${key}:fs`, seq: fs, hold: true}, t, 0)))); 46 | })); 47 | 48 | let rr = el.add(...syn2.render(function(key, gs, fs, vs, i) { 49 | let t = el.train(n64Rate); 50 | let env = el.adsr(0.01, adsrDecay, 0, adsrDecay, el.seq({key: `${key}:gs`, seq: gs, hold: true}, t, 0)); 51 | let gain = el.seq({key: `${key}:vs`, seq: vs, hold: true}, t, 0); 52 | 53 | return el.mul(env, el.sm(gain), el.cycle(el.smooth(el.tau2pole(0.01), el.seq({key: `${key}:fs`, seq: fs, hold: true}, t, 0)))); 54 | })); 55 | 56 | let xl = el.mul(0.2, el.scope({channels: 2}, ll, rr)); 57 | let xr = el.mul(0.2, rr); 58 | 59 | core.render(xl, xr); 60 | 61 | core.on('scope', function(e) { 62 | state.channelData = e.data; 63 | }); 64 | }); 65 | 66 | // Clicking on the document initializes the web audio backend and restarts the 67 | // visualization. 68 | // 69 | // We need this step because most browsers deny an AudioContext from starting 70 | // before some user-initiated interaction. 71 | document.addEventListener('pointerdown', async function start(e) { 72 | if (audioContext.state !== 'running') { 73 | await audioContext.resume(); 74 | } 75 | 76 | let node = await core.initialize(audioContext, { 77 | numberOfInputs: 0, 78 | numberOfOutputs: 1, 79 | outputChannelCount: [2], 80 | }); 81 | 82 | node.connect(audioContext.destination); 83 | 84 | // Reset the canvas state 85 | state.channelData = [ 86 | (new Float32Array(512)).fill(0), 87 | (new Float32Array(512)).fill(0), 88 | ]; 89 | 90 | ctx.clearRect(0, 0, width, height); 91 | state.frameCount = 0; 92 | 93 | document.removeEventListener('pointerdown', start); 94 | }); 95 | 96 | // On resize, we need to scale our canvas size accordingly to preserve the 97 | // correct drawing ratios. 98 | window.addEventListener('resize', function(e) { 99 | width = canvas.width = clone.width = window.innerWidth; 100 | height = canvas.height = clone.height = window.innerHeight; 101 | 102 | // Setting width/height resets the canvas state, need to reapply these 103 | cloneCtx.globalAlpha = globalAlpha; 104 | }); 105 | 106 | // Finally, our draw loop 107 | window.requestAnimationFrame(function loop() { 108 | clear(ctx, cloneCtx, width, height); 109 | draw(ctx, state.frameCount++, width, height, state.channelData); 110 | window.requestAnimationFrame(loop); 111 | }); 112 | -------------------------------------------------------------------------------- /planets/drawing.js: -------------------------------------------------------------------------------- 1 | const palette = Array.from({length: 16}) 2 | .map((x, i) => 4 + ((i / 16) * 360)) 3 | .filter((x) => Math.abs(x - 250) > 25); 4 | 5 | const hue = palette[Math.round(Math.random() * (palette.length - 1))]; 6 | 7 | const phiTilt = (Math.PI / 2) + (Math.random() * Math.PI); 8 | const thetaTilt = (Math.PI / 2) + (Math.random() * Math.PI); 9 | 10 | 11 | // A simple "clear" trick which essentially multiplies the alpha value of any 12 | // existing color content in the canvas, slowly decaying to transparent. 13 | export function clear(ctx, clone, width, height) { 14 | clone.clearRect(0, 0, width, height); 15 | 16 | // Copy our existing context image onto the clone, which has its own globalAlpha, 17 | // so doing this will increase the opacity of the existing drawing that we place onto it 18 | clone.drawImage(ctx.canvas, 0, 0); 19 | 20 | // Then clear our original 21 | ctx.clearRect(0, 0, width, height); 22 | 23 | // Then we can draw back from our clone and we'll get the more-transparent version 24 | ctx.drawImage(clone.canvas, 0, 0); 25 | } 26 | 27 | // The main draw loop 28 | // 29 | // Maps oscilloscope data onto spherical coordinates, then plots the spherical 30 | // positions onto the canvas 31 | export function draw(ctx, frameCount, width, height, [leftData, rightData]) { 32 | // Mid points and radius 33 | let mx = width * 0.5; 34 | let my = height * 0.5; 35 | let mr = Math.min(mx, my) * 0.8; 36 | 37 | // Tilt + slow rotation 38 | let phiDelta = phiTilt + (frameCount / 1317); 39 | let thetaDelta = thetaTilt + (frameCount / 1000); 40 | 41 | // Slowly changing stroke color 42 | ctx.strokeStyle = `hsla(${(hue + (10 * Math.sin(frameCount / 157)) % 360)}, 91%, ${65 + 10 * Math.sin(frameCount / 100)}%, 0.94)`; 43 | ctx.beginPath(); 44 | 45 | { 46 | // Place the channel data in spherical coordinates, then map back to cartesian for plotting 47 | let phi = phiDelta + Math.PI * leftData[0]; 48 | let theta = thetaDelta + Math.PI * rightData[0]; 49 | let radius = mr; 50 | 51 | let x = radius * Math.cos(phi) * Math.sin(theta); 52 | let y = radius * Math.sin(phi) * Math.sin(theta); 53 | let z = radius * Math.cos(theta); 54 | 55 | ctx.moveTo(mx + x, my + z); 56 | } 57 | 58 | for (let i = 1; i < leftData.length; ++i) { 59 | // Place the channel data in spherical coordinates, then map back to cartesian for plotting 60 | let phi = phiDelta + Math.PI * leftData[i]; 61 | let theta = thetaDelta + Math.PI * rightData[i]; 62 | let radius = mr; 63 | 64 | let x = radius * Math.cos(phi) * Math.sin(theta); 65 | let y = radius * Math.sin(phi) * Math.sin(theta); 66 | let z = radius * Math.cos(theta); 67 | 68 | ctx.lineTo(mx + x, my + z); 69 | } 70 | 71 | ctx.stroke(); 72 | } 73 | -------------------------------------------------------------------------------- /planets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elemaudio/web-examples/21ba7fc333f3b4a152930e3c9e7c10474bb0ca15/planets/favicon.ico -------------------------------------------------------------------------------- /planets/genSynth.js: -------------------------------------------------------------------------------- 1 | import teoria from 'teoria'; 2 | 3 | import Synth from './Synth'; 4 | 5 | 6 | // Random seed stuff 7 | let baseNote = teoria.note.fromKey(25 + Math.round(Math.random() * 24)); 8 | let accentNote = teoria.note.fromKey(baseNote.key() + 12); 9 | let scaleType = ['major', 'minor', 'lydian', 'mixolydian'][Math.round(Math.random() * 3)]; 10 | let scale = accentNote.scale(scaleType).notes().concat(baseNote.scale(scaleType).notes()); 11 | 12 | // Creates two synth instances, records a bunch of operations against each, 13 | // and then returns the two as a pair. 14 | // 15 | // We use two synth instances, one for the left channel and one for the right, 16 | // to create a wide stereo field with harmonic ratios, which yields nice 17 | // figures in the Lissajous plot 18 | export default function genSynth() { 19 | const syn1 = new Synth('ll', 4); 20 | const syn2 = new Synth('rr', 4); 21 | const density = 0.02 + Math.random() * 0.2; 22 | const similarity = Math.random(); 23 | 24 | console.log(`${baseNote.toString()} ${scaleType} ${density.toFixed(3)}/${similarity.toFixed(3)}`); 25 | 26 | let nextNote1 = 0; 27 | let nextNote2 = 0; 28 | let atLeastOneNote = false; 29 | 30 | while (!atLeastOneNote) { 31 | syn1.reset(); 32 | syn2.reset(); 33 | 34 | // Eight bars of 16th notes 35 | for (let i = 0; i < 128; ++i) { 36 | let playLeft = Math.random() < density; 37 | let playSimilar = Math.random() < similarity; 38 | 39 | if (playLeft) { 40 | syn1.noteOff(nextNote1); 41 | nextNote1 = scale[Math.floor(Math.random() * (scale.length - 1))].fq(); 42 | syn1.noteOn(nextNote1, 0.125 + Math.random()); 43 | atLeastOneNote = true; 44 | } 45 | 46 | // If we have high similarity, we duplicate the note event into the right 47 | // channel synth. If we fail our similarity check, we optionally play whatever 48 | // note we want in the right channel synth 49 | if (playLeft && playSimilar) { 50 | syn2.noteOff(nextNote2); 51 | nextNote2 = nextNote1; 52 | syn2.noteOn(nextNote2, Math.random()); 53 | } else { 54 | if (Math.random() < density) { 55 | syn2.noteOff(nextNote2); 56 | nextNote2 = scale[Math.floor(Math.random() * (scale.length - 1))].fq(); 57 | syn2.noteOn(nextNote2, 0.125 + Math.random() * 0.5); 58 | atLeastOneNote = true; 59 | } 60 | } 61 | 62 | syn1.step(); 63 | syn2.step(); 64 | } 65 | } 66 | 67 | return [syn1, syn2]; 68 | } 69 | -------------------------------------------------------------------------------- /planets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Planets / Elementary Audio web example 13 | 14 | 15 | 16 | 17 |
18 | 19 |
20 |

Click or tap to start. Refresh to generate a new melody.

21 |

22 | GitHub 23 | – 24 | Elementary Audio 25 |

26 |
27 |
28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /planets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "planets", 3 | "version": "1.0.0", 4 | "description": "A generative, reactive Elementary Audio web example", 5 | "scripts": { 6 | "start": "parcel --no-cache index.html", 7 | "build": "rm -rf dist && parcel build --no-source-maps --no-cache --public-url . index.html", 8 | "predeploy": "npm run build", 9 | "deploy": "gh-pages -d dist" 10 | }, 11 | "author": "Nick Thompson", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "gh-pages": "^3.2.3", 15 | "parcel": "^2.2.1" 16 | }, 17 | "dependencies": { 18 | "@elemaudio/core": "^4.0.1", 19 | "@elemaudio/web-renderer": "^4.0.1", 20 | "teoria": "^2.5.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /planets/styles.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | .main { 7 | background-color: #1E293B; 8 | position: relative; 9 | display: flex; 10 | justify-content: center; 11 | align-items: flex-end; 12 | width: 100vw; 13 | height: 100vh; 14 | } 15 | 16 | .text { 17 | z-index: 1; 18 | color: #F1F5F9; 19 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto Arial, sans-serif; 20 | display: flex; 21 | flex-direction: column; 22 | align-items: center; 23 | padding: 2rem; 24 | } 25 | 26 | .text p { 27 | margin: 0.5rem; 28 | text-align: center; 29 | } 30 | 31 | .link { 32 | text-decoration: none; 33 | color: #94A3B8; 34 | } 35 | 36 | .link:hover { 37 | text-decoration: underline; 38 | color: #64748B; 39 | } 40 | 41 | .canvas { 42 | position: absolute; 43 | left: 0; 44 | top: 0; 45 | } 46 | --------------------------------------------------------------------------------