├── .gitignore ├── deps.ts ├── favicon.ico ├── src ├── rpn.wasm ├── build-wabt.js ├── bytebeat-utils.js ├── note-map.js ├── sandbox.js ├── leb128.js ├── bytebeat-note.js ├── metronome.js ├── lookahead.js ├── oscillator-note.js ├── note.js ├── rpn.c ├── websynth-envelope.js ├── bytebeat-processor.js ├── bytebeat-player.js ├── midinumber.js ├── maths.js └── rpn.js ├── README.md ├── css └── keyboard.css ├── wasmbeat.html ├── test ├── leb128_test.js ├── midinumber_test.js ├── rpn_bench.js └── rpn_test.js ├── wasmbeat.js ├── sandbox.html ├── main.css ├── guide.html ├── index.html ├── ext └── libwabt-LICENSE.txt ├── main.js └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | scratch.* 3 | -------------------------------------------------------------------------------- /deps.ts: -------------------------------------------------------------------------------- 1 | export * from 'https://deno.land/std/testing/asserts.ts' 2 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stellartux/websynth/HEAD/favicon.ico -------------------------------------------------------------------------------- /src/rpn.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stellartux/websynth/HEAD/src/rpn.wasm -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # websynth 2 | 3 | Websynth is a MIDI-controlled synthesiser in the browser. 4 | 5 | [Live version](http://stellartux.github.io/websynth) 6 | 7 | [Documentation](http://stellartux.github.io/websynth/docs) 8 | 9 | Run tests with [Deno](https://deno.land/) 10 | `deno test` 11 | -------------------------------------------------------------------------------- /src/build-wabt.js: -------------------------------------------------------------------------------- 1 | import { WabtModule } from '../ext/libwabt.min.js' 2 | 3 | const wabt = WabtModule() 4 | 5 | /** 6 | * @param {string} code wat code to be parsed 7 | * @param {boolean} [log=false] 8 | * @returns {object} instance, module, log 9 | **/ 10 | export async function buildWabt(code, log = false) { 11 | if (typeof code !== 'string' || code === '') { 12 | throw Error('Bad value passed to buildWabt') 13 | } 14 | const blob = wabt.parseWat('', code, ['multi_value', 'tail_call']) 15 | blob.resolveNames() 16 | blob.validate() 17 | log = Boolean(log) 18 | const bin = blob.toBinary({ log, write_debug_names: log }) 19 | const value = await WebAssembly.instantiate(bin.buffer) 20 | if (log) value.log = bin.log 21 | return value 22 | } 23 | -------------------------------------------------------------------------------- /src/bytebeat-utils.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 3 | /** 4 | * Easy way of checking whether a bytebeat code is valid 5 | * @param {string} bytebeat 6 | * @return {boolean} `true` if the string represents a valid bytebeat code 7 | */ 8 | export function validateBytebeat(bytebeat) { 9 | try { 10 | return typeof evaluateBytebeat(bytebeat)() === 'number' 11 | } catch { 12 | return false 13 | } 14 | } 15 | 16 | /** @param {string} bytebeat */ 17 | export function evaluateBytebeat(bytebeat) { 18 | return new Function( 19 | 't = 0', 20 | 'tt = 0', 21 | `'strict mode';const {abs,floor,round,sqrt,ceil,sin,cos,tan,sinh,cosh,tanh, 22 | asin,acos,asinh,acosh,atan,atanh,atan2,cbrt,sign,trunc,log,exp,min,max, 23 | pow,E,LN2,LN10,LOG10E,PI,SQRT1_2,SQRT2,random} = Math; 24 | const int=(x,i=0)=>typeof(x)==='number'?floor(x):x.charCodeAt(i); 25 | return (${bytebeat});` 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/note-map.js: -------------------------------------------------------------------------------- 1 | /** @extends {Map} For keeping track of groups of pressed keys */ 2 | export class NoteMap extends Map { 3 | /** @param {number} num */ 4 | release(num) { 5 | const note = this.get(num) 6 | note?.releaseNote() 7 | this.delete(num) 8 | return note 9 | } 10 | 11 | releaseAll() { 12 | this.forEach((note) => note.releaseNote()) 13 | this.clear() 14 | } 15 | 16 | /** @param {number} num */ 17 | stop(num) { 18 | this.get(num).stopNote() 19 | this.delete(num) 20 | } 21 | 22 | stopAll() { 23 | this.forEach((note) => note.stopNote()) 24 | this.clear() 25 | } 26 | 27 | /** @param {number} num */ 28 | sustain(num) { 29 | const note = this.get(num) 30 | this.delete(num) 31 | return note 32 | } 33 | 34 | sustainAll() { 35 | const notes = [] 36 | this.forEach((note) => { 37 | notes.push(note) 38 | }) 39 | this.clear() 40 | return notes 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/sandbox.js: -------------------------------------------------------------------------------- 1 | import { RPN } from './rpn.js' 2 | 3 | const $ = x => document.querySelector(x) 4 | const codeInput = $('#code') 5 | const glitchInput = $('#glitch-url') 6 | const glitchName = $('#song-name') 7 | const execT = $('input[name="exec-t"]') 8 | 9 | $('button[name="from-url"]').onclick = () => { 10 | if (RPN.isValidGlitchURL(glitchInput.value)) { 11 | const [name, code] = RPN.fromGlitchURL(glitchInput.value) 12 | codeInput.value = code 13 | glitchName.value = name.replace('-', ' ') 14 | } 15 | } 16 | $('button[name="to-url"]').onclick = () => { 17 | if (RPN.isValidGlitchCode(codeInput.value)) { 18 | glitchInput.value = RPN.toGlitchURL( 19 | codeInput.value, 20 | glitchName.value.toLowerCase().replace(' ', '-') 21 | ) 22 | } 23 | } 24 | $('button[name="execute"]').onclick = () => { 25 | if (RPN.isValidGlitchCode(codeInput.value)) { 26 | $('output[name="execute-result"]').value = RPN.glitchInterpret( 27 | codeInput.value, 28 | Number(execT.value) 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/leb128.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | export class LEB128 extends Uint8Array { 3 | /** @param {number|string|bigint} value the number to be converted */ 4 | constructor(value) { 5 | value = BigInt(value) 6 | /** @type {number[]} */ 7 | const result = [] 8 | while (true) { 9 | /** @type {number} */ 10 | const byte = Number(value & 0x7fn) 11 | value >>= 7n 12 | if (value === (byte & 0x40 ? -1n : 0n)) { 13 | result.push(byte) 14 | super(result) 15 | return this 16 | } 17 | result.push(byte | 0x80) 18 | } 19 | } 20 | toJSON() { 21 | return [...this] 22 | } 23 | toString() { 24 | return this.valueOf().toString() 25 | } 26 | /** @returns {bigint} *///@ts-ignore 27 | valueOf() { 28 | let result = 0n 29 | let shift = 0n 30 | for (const byte of this) { 31 | result |= BigInt(byte & 0x7f) << shift 32 | shift += 7n 33 | if ((byte & 0xc0) === 0x40) { 34 | result |= ~0n << shift 35 | } 36 | } 37 | return result 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /css/keyboard.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --squish: #a4a; 3 | --key-width: 1em; 4 | --half-key: .5em; 5 | } 6 | 7 | #keyboard { 8 | display: flex; 9 | position: relative; 10 | overflow-x: hidden; 11 | width: 100%; 12 | bottom: 0%; 13 | min-height: 3ch; 14 | } 15 | #ivory, #ebony { 16 | height: 100%; 17 | display: flex; 18 | width: 100%; 19 | position: absolute; 20 | justify-content: center; 21 | } 22 | #ebony { 23 | height: 60%; 24 | position: relative; 25 | right: var(--half-key); 26 | } 27 | .key { 28 | position: relative; 29 | margin: 0; 30 | padding: 0; 31 | background-color: #fefee8; 32 | width: var(--key-width); 33 | top: 3%; 34 | height: 97%; 35 | border: outset; 36 | border-top: none; 37 | border-bottom-width: 1.8vh; 38 | box-sizing: border-box; 39 | border-radius: 8%; 40 | } 41 | 42 | #ebony .key { 43 | background-color: #333; 44 | z-index: 1; 45 | position: relative; 46 | border-color: #555; 47 | top: 0%; 48 | } 49 | #keyboard .invisible { 50 | visibility: hidden; 51 | } 52 | #ebony .key:last-child { 53 | visibility: hidden; 54 | } 55 | #keyboard .key.keypress { 56 | background-color: var(--squish); 57 | border-color: var(--squish); 58 | } 59 | -------------------------------------------------------------------------------- /wasmbeat.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Wasmbeat 7 | 20 | 21 | 22 | 23 | 34 |
35 | 36 | 37 | 38 | 39 |
40 |
41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /test/leb128_test.js: -------------------------------------------------------------------------------- 1 | import { assertEquals } from '../deps.ts' 2 | import { LEB128 } from '../src/leb128.js' 3 | 4 | Deno.test({ 5 | name: 'new LEB128(624485)', 6 | fn: function () { 7 | assertEquals([...new LEB128(624485)], [0xe5, 0x8e, 0x26]) 8 | }, 9 | }) 10 | Deno.test({ 11 | name: 'new LEB128(624485n)', 12 | fn: function () { 13 | assertEquals([...new LEB128(624485n)], [0xe5, 0x8e, 0x26]) 14 | }, 15 | }) 16 | Deno.test({ 17 | name: "new LEB128('624485')", 18 | fn: function () { 19 | assertEquals([...new LEB128('624485')], [0xe5, 0x8e, 0x26]) 20 | }, 21 | }) 22 | Deno.test({ 23 | name: "new LEB128('0x98765')", 24 | fn: function () { 25 | assertEquals([...new LEB128('0x98765')], [0xe5, 0x8e, 0x26]) 26 | }, 27 | }) 28 | Deno.test({ 29 | name: 'LEB128.toInt32([0xe5, 0x8e, 0x26])', 30 | fn: function () { 31 | assertEquals(new LEB128(624485).valueOf(), 624485n) 32 | }, 33 | }) 34 | Deno.test({ 35 | name: 'LEB128.toInt32(new LEB128(-98765))', 36 | fn: function () { 37 | assertEquals(new LEB128(-98765).valueOf(), -98765n) 38 | }, 39 | }) 40 | Deno.test({ 41 | name: 'LEB128.toBigInt([0xe5, 0x8e, 0x26])', 42 | fn: function () { 43 | assertEquals(new LEB128(624485n).valueOf(), 624485n) 44 | }, 45 | }) 46 | 47 | if (import.meta.main) Deno.runTests() 48 | -------------------------------------------------------------------------------- /test/midinumber_test.js: -------------------------------------------------------------------------------- 1 | import { assertEquals } from '../deps.ts' 2 | import { MIDINumber } from '../src/midinumber.js' 3 | 4 | Deno.test({ 5 | name: 'MIDINumber.toLetter(n)', 6 | fn: function() { 7 | assertEquals(MIDINumber.toLetter(60), 'C') 8 | assertEquals(MIDINumber.toLetter(64), 'E') 9 | assertEquals(MIDINumber.toLetter(67), 'G') 10 | assertEquals(MIDINumber.toLetter(61), 'C♯') 11 | assertEquals(MIDINumber.toLetter(68), 'G♯') 12 | }, 13 | }) 14 | Deno.test({ 15 | name: 'MIDINumber.toLetter(n, false)', 16 | fn: function() { 17 | assertEquals(MIDINumber.toLetter(60, false), 'C') 18 | assertEquals(MIDINumber.toLetter(64, false), 'E') 19 | assertEquals(MIDINumber.toLetter(67, false), 'G') 20 | assertEquals(MIDINumber.toLetter(61, false), 'D♭') 21 | assertEquals(MIDINumber.toLetter(68, false), 'A♭') 22 | }, 23 | }) 24 | Deno.test({ 25 | name: 'MIDINumber.toChord()', 26 | fn: function() { 27 | assertEquals(MIDINumber.toChord([60, 64, 67]), 'C') 28 | assertEquals(MIDINumber.toChord([61, 65, 68]), 'C♯') 29 | assertEquals(MIDINumber.toChord([61, 65, 68], false), 'D♭') 30 | assertEquals(MIDINumber.toChord([48, 52, 55, 59]), 'CM7') 31 | assertEquals(MIDINumber.toChord([33, 36, 40, 43]), 'Am7') 32 | }, 33 | }) 34 | 35 | if (import.meta.main) Deno.runTests() 36 | -------------------------------------------------------------------------------- /src/bytebeat-note.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | import { Note } from './note.js' 3 | /** @typedef {import('./bytebeat-processor.js').BytebeatProcessorOptions} BytebeatProcessorOptions */ 4 | 5 | export class BytebeatNode extends AudioWorkletNode { 6 | /** 7 | * @param {AudioContext} context 8 | * @param {BytebeatProcessorOptions} processorOptions 9 | */ 10 | constructor(context, processorOptions) { 11 | processorOptions.frequency ??= 8000 12 | processorOptions.sampleRate ??= context.sampleRate 13 | processorOptions.tempo ??= 120 14 | processorOptions.floatMode ??= false 15 | super(context, 'bytebeat-processor', { 16 | numberOfInputs: 0, 17 | numberOfOutputs: 1, 18 | processorOptions, 19 | }) 20 | } 21 | 22 | start(startTime = 0) { 23 | this.port.postMessage({ message: 'start', startTime }) 24 | } 25 | 26 | stop(stopTime = 0) { 27 | this.port.postMessage({ message: 'stop', stopTime }) 28 | } 29 | } 30 | 31 | export class BytebeatNote extends Note { 32 | /** 33 | * A note whose sound is generated by one or more BytebeatNode 34 | * @param {AudioNode|AudioContext} target 35 | * @param {object} noteParams 36 | * @param {BytebeatProcessorOptions[]} oscParams array of oscillator specific options 37 | */ 38 | constructor(target, noteParams, oscParams) { 39 | super(target, noteParams) 40 | for (const param of oscParams) { 41 | const node = new BytebeatNode(this.context, param) 42 | node.connect(this.envGain) 43 | node.start() 44 | this.oscs.push(node) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/metronome.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {(AudioContext|AudioNode)} target output destination for audio 3 | * @param {number} [tempo=120] Tempo of metronome in bpm 4 | */ 5 | export class Metronome { 6 | constructor(target, tempo = 120) { 7 | this.context = target.context || target 8 | this.target = target.destination || target 9 | this.tempo = tempo 10 | this.active = false 11 | this.buffer = this.context.createBuffer( 12 | 1, 13 | Math.floor((1024 * this.context.sampleRate) / 44100), 14 | this.context.sampleRate 15 | ) 16 | this.buffer.getChannelData(0).forEach((_, i, samples) => { 17 | samples[i] = (1 - (i + 1) / samples.length) * (Math.random() * 2 - 1) 18 | }) 19 | } 20 | 21 | get tempo() { 22 | return this.period * 60000 23 | } 24 | /** @param {number} bpm */ 25 | set tempo(bpm) { 26 | this.period = 60000 / bpm 27 | } 28 | 29 | /** Makes a ticking sound 30 | * @param {number} [playbackRate=1] Factor to scale the playback rate by. 31 | */ 32 | tick(playbackRate = 1) { 33 | const tick = this.context.createBufferSource() 34 | tick.buffer = this.buffer 35 | tick.playbackRate.value = playbackRate 36 | tick.connect(this.target) 37 | tick.start() 38 | } 39 | 40 | start() { 41 | this.active = true 42 | /** Makes ticking noises while the metronome is active */ 43 | const keepTicking = () => { 44 | if (this.active) { 45 | this.tick() 46 | window.setTimeout(keepTicking, this.period) 47 | } 48 | } 49 | keepTicking() 50 | } 51 | 52 | stop() { 53 | this.active = false 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /wasmbeat.js: -------------------------------------------------------------------------------- 1 | import { buildWabt } from './src/build-wabt.js' 2 | import { BytebeatNote } from './src/bytebeat-note.js' 3 | const userInput = document.querySelector('textarea[name="code"]') 4 | const output = document.querySelector('output[name="result"]') 5 | const buildLog = document.getElementById('build-log') 6 | const audio = new (window.AudioContext || window.webkitAudioContext)() 7 | const gain = new GainNode(audio, { gain: 0.4 }) 8 | audio.audioWorklet.addModule('./src/bytebeat-processor.js') 9 | gain.connect(audio.destination) 10 | let currentModule 11 | let currentNote 12 | let currentInstance 13 | document.querySelector('button[name="build"]').onclick = async () => { 14 | try { 15 | const { instance, module, log } = await buildWabt(userInput.value, true) 16 | output.innerText = '✔' 17 | buildLog.innerText = log 18 | currentModule = module 19 | currentInstance = instance 20 | } catch (error) { 21 | output.innerText = '❌' 22 | } 23 | } 24 | document.querySelector('button[name="play"]').onclick = async () => { 25 | if (currentModule) { 26 | if (currentNote) currentNote.stop() 27 | currentNote = new BytebeatNote(gain, { attack: 0.01, sustain: 0.5}, [ 28 | { 29 | module: currentModule, 30 | tempo: 120, 31 | frequency: 8000, 32 | }, 33 | ]) 34 | } 35 | } 36 | document.querySelector('button[name="stop"]').onclick = () => { 37 | if (currentNote) { 38 | currentNote.stopNote(0) 39 | currentNote = undefined 40 | } 41 | } 42 | document.querySelector('input[name="gain"]').addEventListener('change', e => { 43 | gain.gain.value = e.target.value 44 | }) 45 | -------------------------------------------------------------------------------- /src/lookahead.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 3 | /** Gives one token of a lookahead to any iterator. */ 4 | export class Lookahead { 5 | #iterator 6 | #fn 7 | /** @type {any[]} */ #next = [] 8 | /** @type {boolean|undefined} */ done 9 | /** @type {any} */ value 10 | 11 | /** 12 | * @param {Iterator} iterator 13 | * @param {(x: IteratorReturnResult) => any} [fn] shouldn't have side effects 14 | */ 15 | constructor(iterator, fn = (x) => x) { 16 | if (!iterator) { 17 | throw new Error(`Iterator ${typeof iterator}`) 18 | } 19 | this.#iterator = iterator 20 | this.#fn = fn 21 | } 22 | 23 | [Symbol.iterator]() { 24 | return this 25 | } 26 | 27 | #peek() { 28 | if (!this.done && this.#next.length === 0) { 29 | const { done, value } = this.#iterator.next() 30 | this.#next.push({ done, value: this.#fn(value) }) 31 | return true 32 | } 33 | return !this.done 34 | } 35 | 36 | next() { 37 | if (this.#peek()) { 38 | const { done, value } = this.#next.shift() 39 | this.done = done 40 | this.value = value 41 | } 42 | return this 43 | } 44 | 45 | /** Peek the next value */ 46 | get [0]() { 47 | if (!this.done && this.#peek()) { 48 | return this.#next[0].value 49 | } 50 | } 51 | 52 | /** Pop the next value */ 53 | shift() { 54 | return this.next().value 55 | } 56 | } 57 | 58 | /** @param {RegExp} regex */ 59 | export function tokenizer(regex) { 60 | /** @param {string} code */ 61 | return function (code) { 62 | return new Lookahead(code.matchAll(regex) ?? [], (result) => result && result[0]) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/oscillator-note.js: -------------------------------------------------------------------------------- 1 | import { Note } from './note.js' 2 | 3 | /** A note whose sound is generated by multiple OscillatorNodes 4 | * @param {Object[]} [oscParams] array of oscillator specific options 5 | * @param {number} [oscParams[].detune=0] detune amount of the oscillator in cents 6 | * @param {number} [oscParams[].frequency=440] frequency of the oscillator in Hertz 7 | * @param {number} [oscParams[].gain=0.5] gain of the oscillator, 0 to 1 inclusive 8 | * @param {string} [oscParams[].type='sine'] waveform shape, options are "sine", "square", "sawtooth", "triangle", "custom" 9 | * @param {number[]} [oscParams[].real] the real part of the custom waveform 10 | * @param {number[]} [oscParams[].imag] the imaginary part of the custom waveform 11 | */ 12 | export class OscillatorNote extends Note { 13 | constructor(target, noteParams = {}, oscParams = [{}]) { 14 | super(target, noteParams) 15 | for (const p of oscParams) { 16 | let gainer = this.context.createGain() 17 | gainer.gain.value = p.gain || 0.5 18 | let osc = this.context.createOscillator() 19 | ;(osc.detune.value = p.detune || 0), 20 | (osc.frequency.value = p.frequency || 440.0) 21 | if (p.type === 'custom') { 22 | osc.setPeriodicWave( 23 | this.context.createPeriodicWave( 24 | new Float32Array(p.real || [0, 1]), 25 | new Float32Array(p.imag || [0, 0]) 26 | ) 27 | ) 28 | } else { 29 | osc.type = p.type || 'sine' 30 | } 31 | osc.connect(gainer).connect(this.envGain) 32 | osc.start() 33 | this.oscs.push(osc) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /sandbox.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | RPN Sandbox 7 | 14 | 15 | 16 | 17 |
18 |
19 | 20 | 21 | 22 |
23 |
24 | 25 |
26 |
27 | 28 | 29 |
30 |
31 | 32 | 33 | 34 |
35 | 36 |
37 | 38 | 39 | 40 | 41 | t 42 | 43 |
44 |
45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/note.js: -------------------------------------------------------------------------------- 1 | /** A musical note composed of one or more oscillators and an ADSR envelope. */ 2 | export class Note { 3 | /** @type {AudioContext} */ context 4 | /** @type {GainNode} */ envGain 5 | attack 6 | decay 7 | sustain 8 | release 9 | velocity 10 | /** @type {number} */ triggerTime 11 | oscs = [] 12 | 13 | /** Create a note 14 | * @param {(AudioContext|AudioNode)} target output destination for audio 15 | * @param {Object} [noteParams] options that affect the note as a whole 16 | * @param {number} [noteParams.attack=0.02] attack time in seconds 17 | * @param {number} [noteParams.decay=0.02] decay time in seconds 18 | * @param {number} [noteParams.release=0.01] release time in seconds 19 | * @param {number} [noteParams.sustain=0.4] sustain level as proportion of peak level, 0 to 1 inclusive 20 | * @param {number} [noteParams.triggerTime=target.currentTime] time to schedule the note (see AudioContext.currentTime) 21 | * @param {number} [noteParams.velocity=1] note velocity, 0 to 1 inclusive 22 | */ 23 | constructor(target, noteParams = {}) { 24 | this.context = target.context || target 25 | this.attack = noteParams.attack || 0.02 26 | this.decay = noteParams.decay || 0.02 27 | this.sustain = noteParams.sustain || 0.4 28 | this.release = noteParams.release || 0.01 29 | this.velocity = noteParams.velocity || 1 30 | this.triggerTime = noteParams.triggerTime || this.context.currentTime 31 | 32 | this.envGain = this.context.createGain() 33 | this.envGain.gain.setValueAtTime(0, this.triggerTime) 34 | this.envGain.gain.linearRampToValueAtTime( 35 | this.velocity, 36 | this.triggerTime + this.attack 37 | ) 38 | this.envGain.gain.setTargetAtTime( 39 | this.sustain * this.velocity, 40 | this.triggerTime + this.attack, 41 | this.decay 42 | ) 43 | this.envGain.connect(target) 44 | } 45 | 46 | /** Trigger the release of the envelope */ 47 | releaseNote() { 48 | this.stopNote(this.context.currentTime + this.release * 20) 49 | this.envGain.gain.setTargetAtTime( 50 | 0, 51 | Math.max( 52 | this.context.currentTime, 53 | this.triggerTime + this.attack + this.decay 54 | ), 55 | this.release 56 | ) 57 | } 58 | 59 | /** Stop all oscillators 60 | * @param {number} [time=AudioContext.currentTime] When to stop playing the note. 61 | */ 62 | stopNote(time = this.context.currentTime) { 63 | for (const o of this.oscs) { 64 | o.stop(time) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /test/rpn_bench.js: -------------------------------------------------------------------------------- 1 | import { bench, runBenchmarks } from '../deps.ts' 2 | import { evaluateBytebeat } from '../src/bytebeat-utils.js' 3 | import { RPN } from '../src/rpn.js' 4 | import * as RPNWASM from '../src/rpn.wasm' 5 | RPN.glitchMachine = RPNWASM 6 | RPN.glitchInterpret('1') 7 | const f = evaluateBytebeat('((t >> 10) & 42) * t') 8 | const fRPN = '(& (>> t 10) 42) t *' 9 | const fWasm = await WebAssembly.instantiate(RPN.toWasmBinary(fRPN)) 10 | 11 | bench({ 12 | name: 'RPN.interpret() - 1s of 128-sample buffers', 13 | runs: 345, 14 | func: b => { 15 | b.start() 16 | for (let t = 0; t < 128; t++) { 17 | RPN.interpret(fRPN, t) 18 | } 19 | b.stop() 20 | }, 21 | }) 22 | bench({ 23 | name: 'RPN.glitchInterpret() - 1s of 128-sample buffers', 24 | runs: 345, 25 | func: b => { 26 | b.start() 27 | for (let t = 0; t < 128; t++) { 28 | RPN.glitchInterpret(fRPN, t) 29 | } 30 | b.stop() 31 | }, 32 | }) 33 | bench({ 34 | name: "RPN.toWasmBinary() - 1s of 128-sample buffers", 35 | runs: 345, 36 | func: b => { 37 | b.start() 38 | for (let t = 0; t < 128; t++) { 39 | fWasm.instance.exports.bytebeat(t) 40 | } 41 | b.stop() 42 | } 43 | }) 44 | bench({ 45 | name: 'equivalent JS code - 1s of 128-sample buffers', 46 | runs: 345, 47 | func: b => { 48 | b.start() 49 | for (let t = 0; t < 128; t++) { 50 | f(t) 51 | } 52 | b.stop() 53 | }, 54 | }) 55 | 56 | 57 | bench({ 58 | name: 'RPN.interpret() - 44100 samples', 59 | runs: 1, 60 | func: b => { 61 | b.start() 62 | for (let t = 0; t < 44100; t++) { 63 | RPN.interpret(fRPN, t) 64 | } 65 | b.stop() 66 | }, 67 | }) 68 | bench({ 69 | name: 'RPN.glitchInterpret() - 44100 samples', 70 | runs: 1, 71 | func: b => { 72 | b.start() 73 | for (let t = 0; t < 44100; t++) { 74 | RPN.glitchInterpret(fRPN, t) 75 | } 76 | b.stop() 77 | }, 78 | }) 79 | bench({ 80 | name: "RPN.toWasmBinary() - 44100 samples", 81 | runs: 1, 82 | func: b => { 83 | b.start() 84 | for (let t = 0; t < 44100; t++) { 85 | fWasm.instance.exports.bytebeat(t) 86 | } 87 | b.stop() 88 | } 89 | }) 90 | bench({ 91 | name: 'equivalent JS code - 44100 samples', 92 | runs: 1, 93 | func: b => { 94 | b.start() 95 | for (let t = 0; t < 44100; t++) { 96 | f(t) 97 | } 98 | b.stop() 99 | }, 100 | }) 101 | 102 | if (import.meta.main) runBenchmarks() 103 | -------------------------------------------------------------------------------- /src/rpn.c: -------------------------------------------------------------------------------- 1 | #define WASM_EXPORT __attribute__((visibility("default"))) 2 | 3 | int memory[256]; 4 | int memPtr = 0; 5 | 6 | WASM_EXPORT 7 | void push(int x) { 8 | memory[memPtr] = x; 9 | memPtr = ++memPtr & 0xff; 10 | } 11 | 12 | int pop() { 13 | memPtr = --memPtr & 0xff; 14 | return memory[memPtr]; 15 | } 16 | 17 | WASM_EXPORT 18 | void add() { 19 | push(pop() + pop()); 20 | } 21 | 22 | WASM_EXPORT 23 | void subtract() { 24 | push(-pop() + pop()); 25 | } 26 | 27 | WASM_EXPORT 28 | void multiply() { 29 | push(pop() * pop()); 30 | } 31 | 32 | WASM_EXPORT 33 | void divide() { 34 | int x = pop(); 35 | push(pop() / x); 36 | } 37 | 38 | WASM_EXPORT 39 | void modulo() { 40 | int x = pop(); 41 | push(pop() % x); 42 | } 43 | 44 | WASM_EXPORT 45 | void bitwiseInvert() { 46 | push(~pop()); 47 | } 48 | 49 | WASM_EXPORT 50 | void shiftRight() { 51 | int x = pop(); 52 | push(pop() >> x); 53 | } 54 | 55 | WASM_EXPORT 56 | void shiftLeft() { 57 | int x = pop(); 58 | push(pop() << x); 59 | } 60 | 61 | WASM_EXPORT 62 | void bitwiseAnd() { 63 | push(pop() & pop()); 64 | } 65 | 66 | WASM_EXPORT 67 | void bitwiseOr() { 68 | push(pop() | pop()); 69 | } 70 | 71 | WASM_EXPORT 72 | void bitwiseXor() { 73 | push(pop() ^ pop()); 74 | } 75 | 76 | WASM_EXPORT 77 | void greaterThan() { 78 | push(pop() > pop() ? 0xffffffff : 0); 79 | } 80 | 81 | WASM_EXPORT 82 | void lessThan() { 83 | push(pop() < pop() ? 0xffffffff : 0); 84 | } 85 | 86 | WASM_EXPORT 87 | void equal() { 88 | push(pop() == pop() ? 0xffffffff : 0); 89 | } 90 | 91 | WASM_EXPORT 92 | void drop() { 93 | pop(); 94 | } 95 | 96 | WASM_EXPORT 97 | void dup() { 98 | int x = pop(); 99 | push(x); 100 | push(x); 101 | } 102 | 103 | WASM_EXPORT 104 | void swap() { 105 | int x = pop(); 106 | int y = pop(); 107 | push(x); 108 | push(y); 109 | } 110 | 111 | WASM_EXPORT 112 | void pick() { 113 | int x = pop(); 114 | push(memory[(memPtr - x) & 0xff]); 115 | } 116 | 117 | WASM_EXPORT 118 | void put() { 119 | int x = pop(); 120 | memory[(memPtr - memory[(memPtr - 1) & 0xff] - 1) & 0xff] = x; 121 | } 122 | 123 | WASM_EXPORT 124 | int popByte() { 125 | return pop() & 0xff; 126 | } 127 | 128 | WASM_EXPORT 129 | int popUint32() { 130 | return pop(); 131 | } 132 | 133 | WASM_EXPORT 134 | float popFloat32() { 135 | return pop() / 2147483648.0 - 1.0; 136 | } 137 | 138 | WASM_EXPORT 139 | float popFloat32Byte() { 140 | return (pop() & 0xff) / 128.0 - 1.0; 141 | } 142 | 143 | WASM_EXPORT 144 | void reset() { 145 | for (int i = 0; i < 256; i++) { 146 | memory[i] = 0; 147 | } 148 | memPtr = 0; 149 | } 150 | -------------------------------------------------------------------------------- /src/websynth-envelope.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | class WebsynthEnvelopeElement extends HTMLElement { 3 | /** @type {Record} */ #inputs = {} 4 | #envelope = { 5 | attack: 0.2, 6 | decay: 0.2, 7 | sustain: 0.2, 8 | release: 0.2, 9 | } 10 | 11 | constructor() { 12 | super() 13 | const shadowRoot = this.attachShadow({ mode: 'open' }) 14 | 15 | const style = document.createElement('style') 16 | style.textContent = ` 17 | div { 18 | border-bottom: solid thin var(--main-shadow); 19 | display: grid; 20 | grid-template-columns: max-content 1fr; 21 | gap: 0.5ch 1ch; 22 | padding: 1ch 2ch; 23 | } 24 | input[type="number"] { 25 | width: min-content; 26 | max-width: 14ch; 27 | min-width: 7ch; 28 | } 29 | label { 30 | text-align: right; 31 | }` 32 | shadowRoot.appendChild(style) 33 | 34 | const wrapper = document.createElement('div') 35 | const type = this.getAttribute('type') ?? 'range' 36 | for (const name of WebsynthEnvelopeElement.#adsr) { 37 | const label = document.createElement('label') 38 | label.setAttribute('for', name) 39 | label.innerText = name[0].toUpperCase() + name.slice(1) 40 | wrapper.appendChild(label) 41 | const slider = document.createElement('input') 42 | slider.setAttribute('id', name) 43 | slider.setAttribute('type', type) 44 | slider.setAttribute('step', '0.001') 45 | slider.setAttribute('min', '0') 46 | slider.setAttribute('max', '1') 47 | slider.setAttribute('value', '0.2') 48 | slider.addEventListener('change', () => { 49 | this.setAttribute(slider.id, slider.value) 50 | }) 51 | slider.addEventListener('dblclick', () => { 52 | this.removeAttribute(slider.id) 53 | }) 54 | this.#inputs[name] = wrapper.appendChild(slider) 55 | } 56 | shadowRoot.appendChild(wrapper) 57 | } 58 | 59 | get envelope() { 60 | return { ...this.#envelope } 61 | } 62 | 63 | /** @param {{ attack?: number, decay?: number, sustain?: number, release?: number }} envelope */ 64 | set envelope(envelope) { 65 | for (const name of WebsynthEnvelopeElement.#adsr) { 66 | if (name in envelope) { 67 | this.setAttribute(name, envelope[name]) 68 | } 69 | } 70 | } 71 | 72 | /** 73 | * @param {string} name 74 | * @param {string} _oldValue 75 | * @param {string} newValue 76 | */ 77 | attributeChangedCallback(name, _oldValue, newValue) { 78 | if (WebsynthEnvelopeElement.#adsr.includes(name)) { 79 | // @ts-ignore 80 | if (!isNaN(newValue)) { 81 | this.#inputs[name].value = newValue ?? 0.2 82 | this.#envelope[name] = Number(this.#inputs[name].value) 83 | } 84 | } else if (name === 'type') { 85 | if (newValue === 'range' || newValue === 'number') { 86 | for (const key of WebsynthEnvelopeElement.#adsr) { 87 | this.#inputs[key].setAttribute('type', newValue) 88 | } 89 | } 90 | } 91 | } 92 | 93 | static get observedAttributes() { 94 | return ['attack', 'decay', 'sustain', 'release', 'type'] 95 | } 96 | 97 | static get #adsr() { 98 | return ['attack', 'decay', 'sustain', 'release'] 99 | } 100 | } 101 | 102 | customElements.define('websynth-envelope', WebsynthEnvelopeElement) 103 | -------------------------------------------------------------------------------- /src/bytebeat-processor.js: -------------------------------------------------------------------------------- 1 | import { evaluateBytebeat } from "./bytebeat-utils.js" 2 | 3 | /** 4 | * @typedef {{ 5 | * bytebeat?: string, 6 | * module?: WebAssembly.Module, 7 | * frequency: number, 8 | * sampleRate: number, 9 | * tempo: number, 10 | * floatMode?: boolean 11 | * }} BytebeatProcessorOptions 12 | * - `bytebeat` Main block of the bytebeat function and its execution 13 | * - `module` A Wasm module which exports a "bytebeat" function 14 | * - `frequency` at which 't' will be incremented 15 | * - `sampleRate` of the audio context 16 | * - `tempo` which the speed that `tt` will be incremented depends on 17 | * - `floatMode` Whether the function expects an output between 0:255 (default) or -1:1 18 | */ 19 | 20 | class BytebeatProcessor extends AudioWorkletProcessor { 21 | t = 0 22 | tt = 0 23 | startTime = Infinity 24 | stopTime = Infinity 25 | sampleRate 26 | frequency 27 | tempo 28 | tDelta 29 | ttDelta 30 | /** @type {Function} */ beatcode 31 | /** @type {(x: number) => number} */ postprocess 32 | 33 | /** 34 | * BytebeatProcessor runs in the AudioWorkletScope. 35 | * @param {{ numberOfInputs: number, numberOfOutputs: number, processorOptions: BytebeatProcessorOptions}} options 36 | */ 37 | constructor(options) { 38 | super(options) 39 | if (options.processorOptions.module) { 40 | WebAssembly.instantiate(options.processorOptions.module).then(mod => { 41 | this.beatcode = mod.exports.bytebeat 42 | }) 43 | } else if (typeof options.processorOptions.bytebeat === 'string') { 44 | this.beatcode = evaluateBytebeat(options.processorOptions.bytebeat) 45 | } else { 46 | throw new TypeError('BytebeatProcessor needs a JavaScript function definition string or a WebAssembly.Module to instantiate.') 47 | } 48 | this.sampleRate = options.processorOptions.sampleRate 49 | this.frequency = options.processorOptions.frequency 50 | this.tempo = options.processorOptions.tempo 51 | this.tDelta = this.frequency / this.sampleRate 52 | this.ttDelta = (this.tempo * 8192) / 120 / this.sampleRate 53 | this.port.onmessage = (/** @type {{ data: { message: any; stopTime: any; startTime: any; }; }} */ event) => { 54 | switch (event.data.message) { 55 | case 'stop': 56 | this.stopTime = currentTime + event.data.stopTime 57 | break 58 | case 'start': 59 | this.startTime = currentTime + event.data.startTime 60 | break 61 | default: 62 | } 63 | } 64 | if (options.processorOptions.floatMode) { 65 | this.postprocess = t => Math.min(1, Math.max(-1, t)) 66 | } else { 67 | this.postprocess = t => (t % 256) / 128 - 1 68 | } 69 | } 70 | 71 | /** 72 | * @param {Float32Array[][]} _inputs 73 | * @param {Float32Array[][]} outputs 74 | **/ 75 | process(_inputs, [output]) { 76 | if (currentTime >= this.startTime) { 77 | let lastT 78 | let lastTt 79 | let data 80 | for (let i = 0; i < output[0].length; ++i) { 81 | const t = Math.floor(this.t) 82 | const tt = Math.floor(this.tt) 83 | if (t !== lastT || tt !== lastTt) { 84 | data = this.postprocess(this.beatcode(t, tt)) 85 | lastT = t 86 | lastTt = tt 87 | } 88 | for (const channel of output) { 89 | channel[i] = data 90 | } 91 | this.t += this.tDelta 92 | this.tt += this.ttDelta 93 | } 94 | } else { 95 | for (const channel of output) { 96 | channel.fill(0) 97 | } 98 | } 99 | return currentTime < this.stopTime 100 | } 101 | } 102 | registerProcessor('bytebeat-processor', BytebeatProcessor) 103 | -------------------------------------------------------------------------------- /test/rpn_test.js: -------------------------------------------------------------------------------- 1 | import { assert, assertEquals } from '../deps.ts' 2 | import { RPN } from '../src/rpn.js' 3 | import * as RPNWASM from '../src/rpn.wasm' 4 | RPN.glitchMachine = RPNWASM 5 | const COMPILED = await WebAssembly.instantiate(RPN.toWasmBinary('t t 8 >> &')) 6 | 7 | Deno.test({ 8 | name: "RPN.interpret('1 1 +')", 9 | fn: () => { 10 | assertEquals(RPN.interpret('1 1 +'), 2) 11 | }, 12 | }) 13 | Deno.test({ 14 | name: "RPN.toGlitchURL('1 1 +', 'name')", 15 | fn: function() { 16 | assertEquals(RPN.toGlitchURL('1 1 +', 'name'), 'glitch://name!1.1f') 17 | }, 18 | }) 19 | Deno.test({ 20 | name: "RPN.fromGlitchURL('glitch://name!1.1f')", 21 | fn: function() { 22 | assertEquals(RPN.fromGlitchURL('glitch://name!1.1f'), ['name', '1 1 +']) 23 | }, 24 | }) 25 | Deno.test({ 26 | name: "RPN.fromGlitchURL('glitch://name!1.1Ff')", 27 | fn: function() { 28 | assertEquals(RPN.fromGlitchURL('glitch://name!1.1Ff'), ['name', '1 31 +']) 29 | }, 30 | }) 31 | Deno.test({ 32 | name: "RPN.desugar('(+ 1 1)')", 33 | fn: function() { 34 | assertEquals(RPN.desugar('(+ 1 1)'), '1 1 +') 35 | }, 36 | }) 37 | Deno.test({ 38 | name: "RPN.desugar('(- 6 (* (+ 1 1) 2))')", 39 | fn: function() { 40 | assertEquals(RPN.desugar('(- 6 (* (+ 1 1) 2))'), '6 1 1 + 2 * -') 41 | }, 42 | }) 43 | Deno.test({ 44 | name: "RPN.desugar('2 (+ 3 (* (- 4 2) (/ 12 3))) +')", 45 | fn: function() { 46 | assertEquals( 47 | RPN.desugar('2 (+ 3 (* (- 4 2) (/ 12 3))) +'), 48 | '2 3 4 2 - 12 3 / * + +' 49 | ) 50 | }, 51 | }) 52 | Deno.test({ 53 | name: "RPN.interpret('(- 6 (* (+ 1 1) 2))')", 54 | fn: function() { 55 | assertEquals(RPN.interpret('(- 6 (* (+ 1 1) 2))'), 2) 56 | }, 57 | }) 58 | Deno.test({ 59 | name: 'RPN.glitchMachine loaded properly', 60 | fn: function() { 61 | assert(RPN.glitchMachine) 62 | }, 63 | }) 64 | Deno.test({ 65 | name: "RPN.glitchInterpret('1 1 +')", 66 | fn: function() { 67 | assertEquals(RPN.glitchInterpret('1 1 +'), 2) 68 | }, 69 | }) 70 | Deno.test({ 71 | name: "RPN.glitchInterpret('2 (+ 3 (* (- 4 2) (/ 12 3))) +')", 72 | fn: function() { 73 | const str = '2 (+ 3 (* (- 4 2) (/ 12 3))) +' 74 | assertEquals(RPN.glitchInterpret(str), RPN.interpret(str)) 75 | }, 76 | }) 77 | Deno.test({ 78 | name: "RPN.glitchInterpret('1 t +', 2) --> 3", 79 | fn: function() { 80 | assertEquals(RPN.glitchInterpret('1 t +', 2), 3) 81 | }, 82 | }) 83 | Deno.test({ 84 | name: 'RPN.glitchInterpret(x) == RPN.interpret(x)', 85 | fn: function() { 86 | const cases = [ 87 | { fn: '1 1 +', ans: 2, t: 0 }, 88 | { fn: '1 t +', ans: 3, t: 2 }, 89 | { fn: '(& (>> t 10) 42) t *', ans: 66, t: 20001 }, 90 | { fn: 't 10 >> 42 & t *', ans: 104, t: 29901 }, 91 | { fn: 't 3 *', ans: 9, t: 3 }, 92 | ] 93 | for (const c of cases) { 94 | const a = RPN.glitchInterpret(c.fn, c.t, 'uint32') 95 | assertEquals(a, RPN.interpret(c.fn, c.t)) 96 | assertEquals(a & 0xff, c.ans) 97 | } 98 | }, 99 | }) 100 | Deno.test({ 101 | name: "RPN.toWat('t t 8 >> &')", 102 | fn: function() { 103 | assertEquals( 104 | RPN.toWat('t t 8 >> &'), 105 | `(module (type $t0 (func (param i32 i32) (result i32))) (func $bytebeat 106 | (export "bytebeat") (type $t0) (param $t i32) (param $tt i32) (result i32) 107 | local.get $t 108 | local.get $t 109 | i32.const 8 110 | i32.shr_s 111 | i32.and))` 112 | ) 113 | }, 114 | }) 115 | Deno.test({ 116 | name: "WebAssembly.validate(RPN.toWasmBinary('t t 8 >> &'))", 117 | fn: function() { 118 | assert(WebAssembly.validate(RPN.toWasmBinary('t t 8 >> &'))) 119 | }, 120 | }) 121 | Deno.test({ 122 | name: "RPN.toWasmBinary(f).bytebeat(x) == RPN.interpret(f, x)", 123 | fn: async function() { 124 | for (let i = 10; i--; ) { 125 | const x = Math.floor(Math.random() * 10000) 126 | assertEquals(COMPILED.instance.exports.bytebeat(x), RPN.interpret('t t 8 >> &', x)) 127 | } 128 | }, 129 | }) 130 | 131 | if (import.meta.main) Deno.runTests() 132 | -------------------------------------------------------------------------------- /main.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --main-bg: #222; 3 | --main-font: #ddd; 4 | --main-shadow: #888; 5 | --neutral: #48d; 6 | --attention: #d84; 7 | --highlight: #8ad; 8 | --good: #4d6; 9 | --bad: #d42; 10 | color-scheme: dark light; 11 | } 12 | @media (prefers-color-scheme: light) { 13 | :root { 14 | --main-font:#333; 15 | --main-bg: #fff; 16 | } 17 | } 18 | * { 19 | box-sizing: border-box; 20 | } 21 | body { 22 | max-height: 100vh; 23 | height: 100vh; 24 | overflow: hidden; 25 | background-color: var(--main-bg); 26 | color: var(--main-font); 27 | font-family: "Aldrich", "Lucida Sans Unicode", "Lucida Grande", "Open Sans", sans-serif; 28 | padding: 0; 29 | margin: 0; 30 | display: grid; 31 | grid-template: 32 | "a a" min-content 33 | "b c" auto 34 | "d d" 2ch 35 | "e e" 20vh / 13fr 5fr; 36 | gap: 0.5ch; 37 | margin: 0 1.3ch; 38 | } 39 | 40 | nav { 41 | padding: 0.3em 0.5em; 42 | border-bottom: thin solid var(--main-shadow); 43 | } 44 | 45 | input, 46 | select { 47 | margin-right: 0.7em; 48 | border-radius: 0.4em; 49 | min-width: 7ch; 50 | max-height: 2rem; 51 | } 52 | 53 | input[type="number"] { 54 | width: 3em; 55 | } 56 | input[type=range] { 57 | min-width: 100px; 58 | color: var(--main-shadow); 59 | } 60 | 61 | #audio-sources { 62 | max-height: 80vh; 63 | overflow-y: auto; 64 | grid-area: b; 65 | width: max(60ch, max-content); 66 | } 67 | #global-controls { 68 | grid-area: c; 69 | } 70 | #keyboard { 71 | grid-area: e; 72 | } 73 | 74 | #oscillator-panel { 75 | flex: 4; 76 | } 77 | button { 78 | position: relative; 79 | color: var(--main-font); 80 | background-color: var(--main-bg); 81 | border: 2px solid var(--main-font); 82 | border-radius: 5vmin; 83 | box-shadow: var(--main-shadow) 0.1em 0.2em 0; 84 | min-width: min-content; 85 | min-height: 2ch; 86 | } 87 | button:active { 88 | color: var(--neutral); 89 | border-color: var(--neutral); 90 | box-shadow: none; 91 | top: 0.2em; 92 | left: 0.1em; 93 | } 94 | button:active.add { 95 | color: var(--good); 96 | border-color: var(--good); 97 | } 98 | button:active.remove { 99 | color: var(--bad); 100 | border-color: var(--bad); 101 | } 102 | #add-oscillator { 103 | position: relative; 104 | height: 5vmin; 105 | width: 5vmin; 106 | margin-left: 0.5em; 107 | border-width: medium; 108 | font-size: 1.2em; 109 | font-weight: bold; 110 | } 111 | 112 | .oscillator { 113 | border: solid thin var(--main-shadow); 114 | padding: 0.2em; 115 | border-left: none; 116 | border-right: none; 117 | text-align: center; 118 | } 119 | 120 | .panel { 121 | padding: 0.7em; 122 | margin-top: 0.5em; 123 | margin-bottom: 0px; 124 | border-bottom: solid thin var(--main-shadow); 125 | display: grid; 126 | grid-template-columns: max-content min-content; 127 | grid-column-gap: 1ch; 128 | text-align: right; 129 | font-size: 0.85rem; 130 | justify-content: space-evenly; 131 | } 132 | 133 | #controller-form { 134 | padding: 1em; 135 | border: solid thick var(--attention); 136 | margin-left: 3vw; 137 | margin-right: 3vw; 138 | margin-bottom: 1em; 139 | display: flex; 140 | justify-content: center; 141 | grid-area: a; 142 | } 143 | 144 | #controller-form > * { 145 | margin-right: 1em; 146 | --neutral: var(--attention); 147 | } 148 | 149 | .invisible, 150 | .oscillator:only-child > button.remove-oscillator { 151 | visibility: hidden; 152 | } 153 | 154 | #controller-form.hidden, 155 | .hidden { 156 | display: none !important; 157 | } 158 | 159 | a { 160 | color: var(--neutral, #88d); 161 | } 162 | a:visited { 163 | color: var(--highlight, #a8d); 164 | } 165 | 166 | #chord-name { 167 | text-align: center; 168 | grid-area: d; 169 | } 170 | #sharp-or-flat::after { 171 | content: '♭'; 172 | color: var(--main-font); 173 | position: relative; 174 | left: 100%; 175 | } 176 | #sharp-or-flat:checked::after { 177 | content: '♯'; 178 | } 179 | 180 | .flexrow { 181 | display: flex; 182 | flex-direction: row; 183 | } 184 | #additive-oscillators { 185 | display: flex; 186 | } 187 | 188 | .validate { 189 | border: thick inset var(--good); 190 | } 191 | .validate:invalid { 192 | border-color: var(--bad); 193 | } 194 | 195 | details { 196 | max-width: 60ch; 197 | padding: 1ch; 198 | } 199 | 200 | textarea { 201 | width: 100%; 202 | } 203 | 204 | #harmonic-series { 205 | align-items: center; 206 | justify-content: center; 207 | display: flex; 208 | gap: 1ch; 209 | flex-direction: column; 210 | font-size: larger; 211 | } 212 | 213 | #harmonic-function { 214 | width: 40ch; 215 | } 216 | -------------------------------------------------------------------------------- /guide.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | A Guide To Bytebeat 7 | 42 | 43 | 44 | 45 | 46 |
47 |

Bytebeat

48 |
49 | 62 |
63 |

What is bytebeat?

64 |

65 | Bytebeat is music generated from short programs. Specifically, these programs generate PCM audio as a function of time. The first explanation I ever read of bytebeat was this web page, and every page I've seen mention bytebeat links back to it too, so I'll carry on the tradition. 66 |

67 | To summarise that page, a bytebeat program is a piece of code, which when put in a loop that increments a value t, generates a piece of music. Below is Crowd, one of the first pieces of bytebeat music discovered. 68 |

((t<<1)^((t<<1)+(t>>7)&t>>12))|t>>(4-(1^7&(t>>19)))|t>>7 69 | Crowd by Kragen, CC-BY 70 |
71 |
72 |

How is audio represented on computers?

73 |

74 | As a list of numbers, just like everything else. The most common way of representing a waveform on a computer is called Linear Pulse Code Modulation, where the list of numbers are called samples and they represent discrete amplitude levels. The samples are spaced evenly in time. 75 |

76 | The most common audio sampling encoding is signed integer 16-bit 44.1kHz, meaning that each sample lasts 1/44100 seconds (about 22 microseconds), and is an integer between -32768 and 32767. Larger bitdepths are typically represented with samples as floats between -1 and 1, allowing the signal to be described independently of the quantization step. The size of the quantization step is 0.5**(n-1) where n is the bitdepth (0.000030517578125 for 16-bit). 77 |

78 | The typical encoding used in bytebeat is unsigned integer 8-bit 8kHz, i.e. each sample is a value between 0 and 255, and is played for 1/8000th of a second (125 microseconds). This encoding is used because it is the default encoding used by aout on Linux, as it was the standard encoding when PCM sound cards first came to market. 79 |

80 |
81 | 104 |
105 |

Helper Functions

106 |

The Math object is included, so sin calls Math.sin, cos calls Math.cos, etc. 107 |

108 |

In C, it is possible to parse a character as an integer. Some bytebeat composers use this to index into a string to iterate through a list of integers in a compact syntax. For example, in C, "HEADACHE"[t%8] would produce the numbers [72, 69, 65, 68, 65, 67, 72, 69] repeatedly, the ASCII character codes for the letters "HEADACHE". 109 |

110 | To imitate this behaviour in JavaScript, the function int works as Math.floor for numbers, and as (x,i) => x.charCodeAt(i) for strings. For example the C code "HEADACHE"[(t>>11)%8] could be written as int("HEADACHE",(t>>11)%8). 111 |

112 | int("HEADACHEGOLDFISH",(tt>>10)%(8+(8&((tt>>14)|(tt>>15)))))*(2**(n=2+((tt>>15-(3&tt>>18))%4))/3**n*n*t&~7&0x1e70>>((tt>>16)%8)) 113 | Headache Goldfish by Stellartux, CC-BY 114 |
115 |
116 |
117 | 118 | 119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /src/bytebeat-player.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | import { BytebeatNode } from './bytebeat-note.js' 3 | import { validateBytebeat } from './bytebeat-utils.js' 4 | 5 | customElements.define( 6 | 'bytebeat-player', 7 | class extends HTMLElement { 8 | #isPlaying = false 9 | constructor() { 10 | super() 11 | const shadow = this.attachShadow({ mode: 'open' }) 12 | const main = document.createElement('main') 13 | this.addEventListener('click', () => this.context.resume(), { 14 | once: true, 15 | }) 16 | this.hasChanged = false 17 | 18 | this.input = document.createElement('textarea') 19 | this.input.setAttribute('name', 'bytebeat') 20 | this.input.setAttribute('placeholder', 't => { // bytebeat }') 21 | this.input.setAttribute('spellcheck', 'false') 22 | this.input.cols = 40 23 | this.input.innerText = this.textContent ?? '' 24 | this.input.oninput = () => { 25 | this.hasChanged = true 26 | this.validate() 27 | } 28 | main.appendChild(this.input) 29 | 30 | const options = document.createElement('div') 31 | 32 | const rateLabel = document.createElement('label') 33 | rateLabel.innerText = 'Rate: ' 34 | const rate = document.createElement('input') 35 | rate.setAttribute('name', 'rate') 36 | rate.setAttribute('type', 'number') 37 | rate.setAttribute('min', '4000') 38 | rate.setAttribute('max', '96000') 39 | rate.value = this.getAttribute('samplerate') || '8000' 40 | rate.onchange = () => (this.hasChanged = true) 41 | rateLabel.appendChild(rate) 42 | options.appendChild(rateLabel) 43 | this.rate = rate 44 | 45 | const tempoLabel = document.createElement('tempo') 46 | tempoLabel.innerText = 'Tempo: ' 47 | const tempo = document.createElement('input') 48 | tempo.setAttribute('name', 'tempo') 49 | tempo.setAttribute('type', 'number') 50 | tempo.setAttribute('min', '60') 51 | tempo.setAttribute('max', '400') 52 | tempo.value = this.getAttribute('tempo') ?? '120' 53 | tempo.onchange = () => (this.hasChanged = true) 54 | tempoLabel.appendChild(tempo) 55 | options.appendChild(tempoLabel) 56 | this.tempo = tempo 57 | 58 | const beatLabel = document.createElement('label') 59 | beatLabel.innerText = 'Mode: ' 60 | const beatType = document.createElement('select'), 61 | optByte = document.createElement('option'), 62 | optFloat = document.createElement('option') 63 | optByte.innerText = 'Bytebeat' 64 | optFloat.innerText = 'Floatbeat' 65 | beatType.appendChild(optByte) 66 | beatType.appendChild(optFloat) 67 | beatLabel.appendChild(beatType) 68 | options.appendChild(beatLabel) 69 | beatType.onchange = () => (this.hasChanged = true) 70 | this.beatType = beatType 71 | 72 | this.context = new AudioContext() 73 | this.limiter = new DynamicsCompressorNode(this.context, { 74 | attack: 0, 75 | knee: 0, 76 | ratio: 20, 77 | release: 0, 78 | threshold: -0.3, 79 | }) 80 | this.limiter.connect(this.context.destination) 81 | this.context.audioWorklet.addModule('src/bytebeat-processor.js') 82 | 83 | const playButton = document.createElement('button') 84 | playButton.addEventListener('click', () => { 85 | if (this.isPlaying && !this.hasChanged) { 86 | this.stop() 87 | } else { 88 | this.play() 89 | } 90 | }) 91 | playButton.textContent = 'Play/Stop' 92 | options.appendChild(playButton) 93 | 94 | this.speakerIcon = document.createElement('span') 95 | this.speakerIcon.innerText = '🔈' 96 | this.speakerIcon.style.visibility = 'hidden' 97 | options.appendChild(this.speakerIcon) 98 | 99 | main.appendChild(options) 100 | 101 | const style = document.createElement('style') 102 | style.innerText = ` 103 | main { 104 | display: grid; 105 | color-scheme: light dark; 106 | } 107 | [name=bytebeat] { 108 | height: max-content; 109 | } 110 | [name=bytebeat]:valid { 111 | border: solid thick green; 112 | } 113 | [name=bytebeat]:invalid { 114 | border: solid thick red; 115 | } 116 | div { 117 | display: flex; 118 | justify-content: space-evenly; 119 | } 120 | [type="number"] { 121 | width: 5ch; 122 | } 123 | [name="rate"] { 124 | min-width: 8ch; 125 | } 126 | ` 127 | shadow.appendChild(style) 128 | shadow.appendChild(main) 129 | } 130 | get value() { 131 | return this.input.value 132 | } 133 | set value(value) { 134 | this.input.value = value 135 | } 136 | get isPlaying() { 137 | return this.#isPlaying 138 | } 139 | set isPlaying(value) { 140 | this.#isPlaying = value 141 | this.speakerIcon.style.visibility = value ? '' : 'hidden' 142 | } 143 | 144 | play() { 145 | if (this.input.validity.valid) { 146 | this.stop() 147 | this.currentPlayingBytebeat = new BytebeatNode(this.context, { 148 | bytebeat: this.value, 149 | frequency: Number(this.rate.value), 150 | tempo: Number(this.tempo.value), 151 | floatMode: this.beatType.value === 'Floatbeat', 152 | sampleRate: this.context.sampleRate 153 | }) 154 | this.currentPlayingBytebeat.connect(this.limiter) 155 | this.currentPlayingBytebeat.start() 156 | } 157 | this.hasChanged = false 158 | this.isPlaying = true 159 | } 160 | stop() { 161 | if (this.currentPlayingBytebeat) { 162 | this.currentPlayingBytebeat.stop() 163 | } 164 | this.isPlaying = false 165 | } 166 | validate() { 167 | this.input.setCustomValidity( 168 | validateBytebeat(this.value) ? '' : 'Invalid bytebeat' 169 | ) 170 | } 171 | } 172 | ) 173 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Websynth 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 25 | 26 |
27 | 36 | 37 |
38 | 39 |
    40 | 41 | 69 |
    70 | 71 | 122 | 123 | 151 | 152 | 173 |
    174 | 175 |
    176 | 177 |
    178 | 182 | 183 | 184 |
    185 | 186 | 187 |
    188 |
    189 | 190 |
    191 | 192 | 193 | 194 | 195 | 196 | 197 |
    198 | 199 | 200 | 201 |
    202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 |
    213 |
    214 | 215 |
    216 |
    217 |
    218 |
    219 | 220 | 221 | 222 | -------------------------------------------------------------------------------- /src/midinumber.js: -------------------------------------------------------------------------------- 1 | /** A helper singleton for converting MIDI note numbers into other representations of musical notes. */ 2 | export class MIDINumber { 3 | /** 4 | * Calculates the name of a chord from an array of MIDI note numbers. 5 | * If a name can't be found, lists all held notes, separated by long dashes. 6 | * @param {number[]} nums An array of MIDI note numbers. 7 | * @param {boolean} [sharp=true] Format accidentals as sharp or flat 8 | * @param {boolean} [verbose=false] Format as short or verbose form of chord names 9 | * @returns {string} The chord name of the held notes. 10 | */ 11 | static toChord(nums, sharp = true, verbose = false) { 12 | const ns = this.makeUnique(nums) 13 | return ( 14 | this.toPentaChord(ns, sharp, verbose) || 15 | this.toTetrachord(ns, sharp, verbose) || 16 | this.toTriad(ns, sharp, verbose) || 17 | (verbose && this.toInterval(nums)) || 18 | nums.map(n => this.toLetter(n, sharp)).join('–') 19 | ) 20 | } 21 | 22 | /** Removes any notes which are an octave multiple of a lower note. 23 | * @example 24 | * // returns [60, 64, 67, 74] 25 | * MIDINumber.makeUnique([60, 64, 67, 72, 74, 76, 79]) 26 | * @param {number[]} nums An array of MIDI note numbers 27 | * @returns {number[]} An array of MIDI note numbers 28 | */ 29 | static makeUnique(nums) { 30 | const indices = [] 31 | for (const n in nums) { 32 | if ( 33 | nums[n] - nums[0] < 12 || 34 | indices.every(i => nums[i] % 12 !== nums[n] % 12) 35 | ) { 36 | indices.push(n) 37 | } 38 | } 39 | return indices.map(i => nums[i]) 40 | } 41 | 42 | /** Converts a MIDI number to its frequency in equal temperament. 43 | * @param {number} num A MIDI note number. Floating point numbers are interpolated logarithmically. 44 | * @returns {number} The note's frequency in Hertz 45 | */ 46 | static toFrequency(num) { 47 | return 13.75 * Math.pow(2, (num - 9) / 12) 48 | } 49 | 50 | static get sharpnotes() { 51 | return ['C', 'C♯', 'D', 'D♯', 'E', 'F', 'F♯', 'G', 'G♯', 'A', 'A♯', 'B'] 52 | } 53 | static get flatnotes() { 54 | return ['C', 'D♭', 'D', 'E♭', 'E', 'F', 'G♭', 'G', 'A♭', 'A', 'B♭', 'B'] 55 | } 56 | 57 | /** Converts a MIDI number to its note name. 58 | * @param {number} num A MIDI number. 59 | * @param {boolean} [sharp=true] Format accidentals as sharp or flat. 60 | * @returns {string} The note name 61 | */ 62 | static toLetter(num, sharp = true) { 63 | return (sharp ? this.sharpnotes : this.flatnotes)[num % 12] 64 | } 65 | 66 | /** Converts a MIDI number to scientific pitch notation. 67 | * @example 68 | * // returns 'C5' 69 | * MIDINumber.toScientificPitch(60) 70 | * @param {number} num A MIDI number. 71 | * @param {boolean} [sharp=true] Format accidentals as sharp or flat. 72 | * @returns {string} The note name and octave number 73 | */ 74 | static toScientificPitch(num, sharp = true) { 75 | return this.toLetter(num, sharp) + Math.floor(num / 12) 76 | } 77 | 78 | /** Converts from scientific pitch back to a MIDI note number 79 | * @param {string} pitch A music note in scientific pitch notation 80 | * @returns {number} the pitch as a MIDI note number 81 | */ 82 | static fromScientificPitch(pitch) { 83 | const matches = pitch.match(/(\D+)(\d+)/), 84 | letter = matches[1].toUpperCase() 85 | let n 86 | if (this.sharpnotes.includes(letter)) { 87 | n = this.sharpnotes.indexOf(letter) 88 | } else if (this.flatnotes.includes(letter)) { 89 | n = this.flatnotes.indexOf(letter) 90 | } else { 91 | throw 'Note name could not be found' 92 | } 93 | return n + 12 * (8 + Number(matches[2])) 94 | } 95 | 96 | /** Calculates the interval between a pair of MIDI numbers. 97 | * @param {number[]} nums The MIDI numbers to find the interval between. 98 | * @returns {string} The interval between the notes. 99 | */ 100 | static toInterval(nums) { 101 | return nums.length !== 2 102 | ? '' 103 | : [ 104 | 'octave', 105 | 'minor second', 106 | 'major second', 107 | 'minor third', 108 | 'major third', 109 | 'perfect fourth', 110 | 'diminished fifth', 111 | 'perfect fifth', 112 | 'minor sixth', 113 | 'major sixth', 114 | 'minor seventh', 115 | 'major seventh', 116 | ][(nums[1] - nums[0]) % 12] 117 | } 118 | 119 | /** Calculates the chord represented by three MIDI numbers. 120 | * @param {number[]} nums The MIDI numbers to find the chord of. 121 | * @param {boolean} [sharp=true] Format accidentals as sharp or flat. 122 | * @param {boolean} [verbose=false] Format as short or verbose form of chord names 123 | * @returns {string} The calculated chord, or an empty string if no chord is found. 124 | */ 125 | static toTriad(nums, sharp = true, verbose = false) { 126 | if (nums.length !== 3) return '' 127 | const tl = l => this.toLetter(nums[l], sharp) 128 | return ( 129 | (verbose 130 | ? { 131 | '047': tl(0) + ' major', 132 | '0716': tl(0) + ' major', 133 | '038': tl(2) + ' major', 134 | '0815': tl(1) + ' major', 135 | '059': tl(1) + ' major', 136 | '0917': tl(2) + ' major', 137 | '037': tl(0) + ' minor', 138 | '049': tl(2) + ' minor', 139 | '058': tl(1) + ' minor', 140 | '0715': tl(0) + ' minor', 141 | '0916': tl(1) + ' minor', 142 | '0817': tl(2) + ' minor', 143 | '036': tl(0) + ' diminished', 144 | '039': tl(2) + ' diminished', 145 | '069': tl(1) + ' diminished', 146 | '0615': tl(0) + ' diminished', 147 | '0915': tl(2) + ' diminished', 148 | '0918': tl(1) + ' diminished', 149 | '027': tl(0) + ' suspended second', 150 | '0714': tl(0) + ' suspended second', 151 | '057': tl(0) + ' suspended fourth', 152 | '048': tl(0) + ' augmented', 153 | '0410': tl(0) + ' seventh, no fifth', 154 | '0411': tl(0) + ' major seventh, no fifth', 155 | '0310': tl(0) + ' minor seventh, no fifth', 156 | '0311': tl(0) + ' minor major seventh, no fifth', 157 | } 158 | : { 159 | '047': tl(0), 160 | '0716': tl(0), 161 | '038': tl(2), 162 | '0815': tl(1), 163 | '059': tl(1), 164 | '0917': tl(2), 165 | '037': tl(0) + 'm', 166 | '058': tl(1) + 'm', 167 | '049': tl(2) + 'm', 168 | '0715': tl(0) + 'm', 169 | '0916': tl(1) + 'm', 170 | '0817': tl(2) + 'm', 171 | '036': tl(0) + 'ᴼ', 172 | '069': tl(1) + 'ᴼ', 173 | '039': tl(2) + 'ᴼ', 174 | '0615': tl(0) + 'ᴼ', 175 | '0918': tl(1) + 'ᴼ', 176 | '0915': tl(2) + 'ᴼ', 177 | '027': tl(0) + 'sus2', 178 | '0714': tl(0) + 'sus2', 179 | '057': tl(0) + 'sus4', 180 | '048': tl(0) + '+', 181 | '0410': tl(0) + '7no5', 182 | '0411': tl(0) + 'M7no5', 183 | '0310': tl(0) + 'm7no5', 184 | '0311': tl(0) + 'mM7no5', 185 | })[nums.map(n => n - nums[0]).join('')] || '' 186 | ) 187 | } 188 | 189 | /** Calculates the chord represented by four MIDI numbers. 190 | * @param {number[]} nums The MIDI numbers to find the chord of. 191 | * @param {boolean} [sharp=true] Format accidentals as sharp or flat. 192 | * @param {boolean} [verbose=false] Format as short or verbose form of chord names 193 | * @param {boolean} [slashOkay=true] Return a slash chord if no tetrachord is found. 194 | * @returns {string} The calculated chord, or an empty string if no chord is found. 195 | */ 196 | static toTetrachord(nums, sharp = true, verbose = false, slashOkay = true) { 197 | if (nums.length !== 4) return '' 198 | const tl = this.toLetter(nums[0], sharp) 199 | const chord = (verbose 200 | ? { 201 | '04710': ' dominant seventh', 202 | '04610': ' dominant seventh flat five', 203 | '04711': ' major seventh', 204 | '04714': ' added ninth', 205 | '03710': ' minor seventh', 206 | '03711': ' minor major seventh', 207 | '0369': ' diminished seventh', 208 | '03610': ' half-diminished seventh', 209 | '04811': ' augmented major seventh', 210 | '04810': ' augmented seventh', 211 | '0479': ' major sixth', 212 | '0379': ' minor sixth', 213 | '0457': ' added fourth', 214 | '0357': ' minor added fourth', 215 | '0347': ' mixed third', 216 | } 217 | : { 218 | '04710': '7', 219 | '04610': '7♭5', 220 | '04711': 'M7', 221 | '04714': 'add9', 222 | '03710': 'm7', 223 | '03711': 'mM7', 224 | '0369': 'ᴼ7', 225 | '03610': 'Ø7', 226 | '04811': '+M7', 227 | '04810': '+7', 228 | '0479': '6', 229 | '0379': 'm6', 230 | '0457': 'add4', 231 | '0357': 'madd4', 232 | '0347': '¬', 233 | })[nums.map(n => n - nums[0]).join('')] 234 | if (chord) { 235 | return tl + chord 236 | } 237 | const upperChord = this.toTriad(nums.slice(1), sharp, verbose) 238 | if (slashOkay && upperChord) { 239 | return upperChord + (verbose ? ' over ' : '/') + tl 240 | } else { 241 | return '' 242 | } 243 | } 244 | 245 | /** Calculates the chord represented by five MIDI numbers. 246 | * @param {number[]} nums The MIDI numbers to find the chord of. 247 | * @param {boolean} [sharp=true] Format accidentals as sharp or flat. 248 | * @param {boolean} [verbose=false] Format as short or verbose form of chord names 249 | * @param {boolean} [slashOkay=true] Return a slash chord if no pentachord is found. 250 | * @returns {string} The calculated chord, or an empty string if no chord is found. 251 | */ 252 | static toPentaChord(nums, sharp = true, verbose = false, slashOkay = true) { 253 | if (nums.length !== 5) { 254 | return '' 255 | } 256 | const tl = this.toLetter(nums[0], sharp) 257 | const chord = (verbose 258 | ? { 259 | '0471114': ' major ninth', 260 | '0471014': ' dominant ninth', 261 | '0371114': ' minor major ninth', 262 | '0371014': ' minor ninth', 263 | '0481114': ' augmented major ninth', 264 | '0481014': ' augmented dominant ninth', 265 | '0361014': ' half-diminished ninth', 266 | '0361013': ' half-diminished minor ninth', 267 | '036914': ' diminished ninth', 268 | '036913': ' diminished minor ninth', 269 | } 270 | : { 271 | '0471114': 'M9', 272 | '0471014': '9', 273 | '0371114': 'mM9', 274 | '0371014': 'm9', 275 | '0481114': '+M9', 276 | '0481014': '+9', 277 | '0361014': 'ø9', 278 | '0361013': 'ø♭9', 279 | '036914': 'ᴼ9', 280 | '036913': 'ᴼ♭9', 281 | })[nums.map(n => n - nums[0]).join('')] 282 | if (chord) { 283 | return tl + chord 284 | } 285 | const upperChord = this.toTetrachord(nums.slice(1), sharp, verbose, false) 286 | if (slashOkay && upperChord) { 287 | return upperChord + (verbose ? ' over ' : '/') + tl 288 | } else { 289 | return '' 290 | } 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /ext/libwabt-LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /src/maths.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | import { tokenizer } from "./lookahead.js" 3 | 4 | export class ComplexNumber { 5 | /** @type {number} */ re 6 | /** @type {number} */ im 7 | /** 8 | * @param {number|ComplexNumber} re 9 | * @param {number} [im=0] 10 | */ 11 | constructor(re, im = 0) { 12 | if (typeof re === 'string' && !isNaN(re)) re = Number(re) 13 | if (typeof im === 'string' && !isNaN(im)) im = Number(im) 14 | if (typeof re === 'number') { 15 | this.re = re 16 | this.im = im 17 | } else if (re instanceof ComplexNumber) { 18 | this.re = re.re 19 | this.im = re.im 20 | } else { 21 | throw new TypeError('re must be a number.') 22 | } 23 | if (typeof im !== 'number') throw new TypeError('im must be a number.') 24 | } 25 | /** @param {number|ComplexNumber?} that */ 26 | ['+'](that) { 27 | if (that instanceof ComplexNumber || typeof that === 'number') { 28 | return new ComplexNumber(this)['+='](that) 29 | } else if (that === undefined) { 30 | return this 31 | } else { 32 | throw new TypeError('Unexpected ' + typeof that) 33 | } 34 | } 35 | /** @param {number|ComplexNumber} that */ 36 | ['+='](that) { 37 | if (that instanceof ComplexNumber) { 38 | this.re += that.re 39 | this.im += that.im 40 | } else if (typeof that === 'number') { 41 | this.re += that 42 | } else { 43 | throw new TypeError('Unexpected ' + typeof that) 44 | } 45 | return this 46 | } 47 | /** @param {number|ComplexNumber?} that */ 48 | ['-'](that) { 49 | if (that instanceof ComplexNumber || typeof that === 'number') { 50 | return new ComplexNumber(this)['-='](that) 51 | } else if (that === undefined) { 52 | return new ComplexNumber(-this.re, -this.im) 53 | } else { 54 | throw new TypeError('Unexpected ' + typeof that) 55 | } 56 | } 57 | /** @param {number|ComplexNumber} that */ 58 | ['-='](that) { 59 | if (that instanceof ComplexNumber) { 60 | const a = this.re, b = this.im, c = that.re, d = that.im 61 | return new ComplexNumber(a - c, b - d) 62 | } else if (typeof that === 'number') { 63 | return new ComplexNumber(this.re - that, this.im) 64 | } else { 65 | throw new TypeError('Unexpected ' + typeof that) 66 | } 67 | } 68 | /** @param {number|ComplexNumber} that */ 69 | ['*'](that) { 70 | return new ComplexNumber(this)['*='](that) 71 | } 72 | /** @param {number|ComplexNumber} that */ 73 | ['*='](that) { 74 | if (that instanceof ComplexNumber) { 75 | const a = this.re, b = this.im, c = that.re, d = that.im 76 | this.re = a * c - b * d 77 | this.im = b * c + a * d 78 | } else if (typeof that === 'number') { 79 | this.re *= that 80 | this.im *= that 81 | } else { 82 | throw new TypeError('Unexpected ' + typeof that) 83 | } 84 | return this 85 | } 86 | /** @param {ComplexNumber} that */ 87 | ['/'](that) { 88 | const a = this.re, b = this.im, c = that.re, d = that.im 89 | const q = c ** 2 + d ** 2 90 | // for pragmatic reasons `x / 0 === 0` 91 | if (q === 0) return new ComplexNumber(0) 92 | return new ComplexNumber((a * c + b * d) / q, (b * c - a * d) / q) 93 | } 94 | /** @param {ComplexNumber} that */ 95 | ['^'](that) { 96 | if (this.im === 0) { 97 | if (that.im === 0) { 98 | return new ComplexNumber(this.re ** that.re) 99 | } else { 100 | return that['*'](new ComplexNumber(Math.log(this.re))).exp() 101 | } 102 | } else if (that.im === 0 && that.re > 0 && Number.isSafeInteger(that.re)) { 103 | let result = new ComplexNumber(1) 104 | let temp = new ComplexNumber(this) 105 | for (let power = that.re; power > 0; power >>>= 1) { 106 | if (power & 1) { 107 | result['*='](temp) 108 | } 109 | temp['+='](temp) 110 | } 111 | return result 112 | } else { 113 | throw new Error('Unimplemented exponentiation type') 114 | } 115 | } 116 | /** @param {ComplexNumber} that */ 117 | ['=='](that) { 118 | return this.re === that.re && this.im === that.im 119 | } 120 | abs() { 121 | return new ComplexNumber(Math.hypot(this.re, this.im)) 122 | } 123 | ["'"]() { 124 | return new ComplexNumber(this.re, -this.im) 125 | } 126 | exp() { 127 | const a = this.re, b = this.im, e = Math.E 128 | return new ComplexNumber(Math.cos(b), Math.sin(b))['*'](new ComplexNumber(e ** a)) 129 | } 130 | /** @returns {ComplexNumber} the square roots are `result` and `result.conj` */ 131 | sqrt() { 132 | const a = this.re, b = this.im, h = Math.hypot(a, b), sgn = (b >= 0 ? 1 : -1) 133 | return new ComplexNumber(Math.sqrt((a + h) / 2), sgn * Math.sqrt((-a + h) / 2)) 134 | } 135 | toString() { 136 | return `ComplexNumber(${this.re}, ${this.im})` 137 | } 138 | get [Symbol.toStringTag]() { 139 | const im = Math.abs(this.im) 140 | if (im !== 0) { 141 | return `${this.re} ${this.im >= 0 ? '+' : '-'} ${im === 1 ? '' : im}i` 142 | } else { 143 | return this.re.toString() 144 | } 145 | } 146 | /** @param {string} json */ 147 | static fromJSON(json) { 148 | const { re, im } = JSON.parse(json) 149 | return new ComplexNumber(re, im) 150 | } 151 | toMathML(brackets = false) { 152 | if (this.im === 0) { 153 | return `${this.re}` 154 | } else if (this.re === 0) { 155 | return `${this.im === 1 ? '' : this.im}i` 156 | } else { 157 | const im = Math.abs(this.im) 158 | return `${brackets ? '(' : '' 159 | }${this.re 160 | }${this.im > 0 ? '+' : '-' 161 | }${im === 1 ? '' : im}i${brackets ? ')' : '' 162 | }` 163 | } 164 | } 165 | } 166 | 167 | /** @typedef {string[] | import("./lookahead.js").Lookahead} Tokens */ 168 | /** @typedef {Array|ComplexNumber|string} Expr */ 169 | 170 | /** 171 | * @param {string} expected 172 | * @param {Tokens} tokens 173 | */ 174 | function expect(expected, tokens) { 175 | const actual = tokens.shift() 176 | if (expected !== actual) { 177 | throw new Error(`Expected "${expected}" but got "${actual}"`) 178 | } 179 | return tokens 180 | } 181 | 182 | const tokenize = tokenizer(/\d+(\.\d+)?|\*\*?|[+\-\/()^'πℯ]|im?|[jkN]|abs|e(xp)?|pi|sqrt/g) 183 | 184 | /** @param {string} code */ 185 | export function validate(code) { 186 | return /\S/.test(code) && /^(\d+(\.\d+)?|\*\*?|[+\-\/()^'πℯ]|im?|[jkN]|abs|e(xp)?|pi|sqrt|\s)*$/.test(code) 187 | } 188 | 189 | function isImaginaryUnit(token = '') { 190 | return /^(im?|j)$/.test(token) 191 | } 192 | 193 | const operators = { 194 | '+': { precedence: 1, leftAssociative: true, }, 195 | '-': { precedence: 1, leftAssociative: true, }, 196 | '*': { precedence: 2, leftAssociative: true, }, 197 | '/': { precedence: 2, leftAssociative: true, }, 198 | '^': { precedence: 3, leftAssociative: false, }, 199 | '**': { precedence: 3, leftAssociative: false, alternative: '^', }, 200 | maxPrecedence: 3, 201 | } 202 | 203 | /** @param {Tokens} tokens */ 204 | function binop(tokens, precedence = 0) { 205 | if (precedence > operators.maxPrecedence) { 206 | return unop(tokens) 207 | } 208 | let expr = binop(tokens, precedence + 1) 209 | for (let operator = operators[tokens[0]]; 210 | operator && operator.precedence === precedence; 211 | operator = operators[tokens[0]] 212 | ) { 213 | let op = tokens.shift() 214 | if (operator.alternative) op = operator.alternative 215 | expr = [op, expr, binop(tokens, precedence + operator.leftAssociative)] 216 | } 217 | return expr 218 | } 219 | 220 | const pi = new ComplexNumber(Math.PI) 221 | const e = new ComplexNumber(Math.E) 222 | const im = new ComplexNumber(0, 1) 223 | const constants = { e, 𝑒: e, pi, π: pi, im, i: im, j: im } 224 | 225 | /** @param {Tokens} tokens */ 226 | function unop(tokens) { 227 | let token = tokens.shift() 228 | if ('+-'.includes(token)) { 229 | return [token, unop(tokens)] 230 | } else if (token === '(') { 231 | const term = binop(tokens) 232 | tokens.shift() 233 | return postfix(term, tokens) 234 | } else if (!isNaN(Number(token))) { 235 | if (isImaginaryUnit(tokens[0])) { 236 | tokens.shift() 237 | return postfix(new ComplexNumber(0, Number(token)), tokens) 238 | } 239 | const value = new ComplexNumber(Number(token)) 240 | if (tokens[0] && /^[[:alpha:]_]+$/.test(tokens[0])) { 241 | return postfix(['*', value, tokens.shift()], tokens) 242 | } else { 243 | return postfix(value, tokens) 244 | } 245 | } else if (isImaginaryUnit(token)) { 246 | return postfix(new ComplexNumber(0, 1), tokens) 247 | } else { 248 | return postfix(token, tokens) 249 | } 250 | } 251 | 252 | /** 253 | * @param {Expr} expr 254 | * @param {Tokens} tokens 255 | */ 256 | function postfix(expr, tokens) { 257 | if (tokens[0] === "'") { 258 | return postfix([tokens.shift(), expr], tokens) 259 | } else if (typeof expr === 'string' && tokens[0] === '(') { 260 | tokens.shift() 261 | return postfix([expr, binop(tokens)], expect(')', tokens)) 262 | } else { 263 | return expr 264 | } 265 | } 266 | 267 | /** 268 | * @param {Expr} expr 269 | * @return {Expr} 270 | */ 271 | export function invert(expr) { 272 | if (Array.isArray(expr) && expr[0] === '/') { 273 | if (expr[1] instanceof ComplexNumber && expr[1]['=='](new ComplexNumber(1))) { 274 | return expr[2] 275 | } 276 | return ['/', expr[2], expr[1]] 277 | } else { 278 | return ['/', new ComplexNumber(1), expr] 279 | } 280 | } 281 | 282 | /** 283 | * @param {Expr} expr 284 | * @return {string} 285 | */ 286 | export function mathML(expr, parentOperator = '*', lhs = true) { 287 | /** @param {string[]} result */ 288 | function addBracketsToResult(result) { 289 | result.splice(1, 0, '(') 290 | result.splice(-1, 0, ')') 291 | } 292 | if (typeof expr === 'string') { 293 | if (expr === 'pi') { 294 | expr = 'π' 295 | } else if (expr === 'e') { 296 | expr = '𝑒' 297 | } 298 | return `${expr}` 299 | } 300 | const needsBrackets = () => parentOperator === '*' || parentOperator === "'" || (parentOperator === '^' && lhs) 301 | if (Array.isArray(expr)) { 302 | const op = expr[0] 303 | if (op === '/') { 304 | return `${mathML(expr[1], op)}${mathML(expr[2], op, false)}` 305 | } else if (op === '^') { 306 | return `${mathML(expr[1], op)}${mathML(expr[2], op, false)}` 307 | } else if (op === '+' || op === '-') { 308 | let result = ['', '', op, '', ''] 309 | if (expr[2] === undefined) { 310 | result.splice(-1, 0, mathML(expr[1], op)) 311 | } else { 312 | result.splice(1, 0, mathML(expr[1], op)) 313 | result.splice(-1, 0, mathML(expr[2], op, false)) 314 | } 315 | if (needsBrackets()) addBracketsToResult(result) 316 | return result.join('') 317 | } else if (op === '*') { 318 | let result = ['', mathML(expr[1], op), mathML(expr[2], op, false), ''] 319 | if (!(op === '*' && typeof expr[2] === 'string' && expr[1] instanceof ComplexNumber && expr[1].im === 0)) { 320 | result.splice(2, 0, '·') 321 | if (parentOperator === '*' && !lhs || parentOperator === "'" || (parentOperator === '^' && lhs)) { 322 | addBracketsToResult(result) 323 | } 324 | } 325 | return result.join('') 326 | } else if (op === 'sqrt') { 327 | return `${mathML(expr[1], op)}` 328 | } else if (op === "'") { 329 | return `${mathML(expr[1], op)}'` 330 | } else { 331 | return `${mathML(op)}(${mathML(expr[1], op)})` 332 | } 333 | } else if (expr instanceof ComplexNumber) { 334 | return expr.toMathML(needsBrackets()) 335 | } else { 336 | throw new Error(`Unexpected: ${expr}`) 337 | } 338 | } 339 | 340 | /** @param {Expr} expr */ 341 | export function sexpr(expr) { 342 | if (Array.isArray(expr)) { 343 | return '(' + expr.map(sexpr).join(' ') + ')' 344 | } else if (expr instanceof ComplexNumber) { 345 | return `#C(${expr.re} ${expr.im})` 346 | } else { 347 | return expr 348 | } 349 | } 350 | 351 | /** @param {string} code */ 352 | export function parse(code) { 353 | const tokens = tokenize(code) 354 | const expr = binop(tokens) 355 | if (tokens.next().value) { 356 | throw new Error('Excess tokens.') 357 | } 358 | return evaluate(expr, { im, i: im, j: im }) 359 | } 360 | 361 | /** 362 | * @param {Expr} expr 363 | * @param {Record} ctx 364 | */ 365 | export function evaluate(expr, ctx = constants) { 366 | if (Array.isArray(expr)) { 367 | const [left, right] = expr.slice(1).map((arg) => evaluate(arg, ctx)) 368 | if (left instanceof ComplexNumber && (right instanceof ComplexNumber || right === undefined)) { 369 | return left[expr[0]](right) 370 | } else if (right) { 371 | return [expr[0], left, right] 372 | } else { 373 | return [expr[0], left] 374 | } 375 | } else if (typeof expr === 'string') { 376 | if (expr in ctx) { 377 | return ctx[expr] 378 | } else { 379 | return expr 380 | } 381 | } else if (expr instanceof ComplexNumber) { 382 | return expr 383 | } else { 384 | throw new Error(`Unexpected ${typeof expr}: ${expr}`) 385 | } 386 | } 387 | -------------------------------------------------------------------------------- /src/rpn.js: -------------------------------------------------------------------------------- 1 | import { LEB128 } from './leb128.js' 2 | 3 | /** 4 | * Functions for interpreting Reverse Polish Notation code 5 | * @namespace RPN 6 | */ 7 | export const RPN = { 8 | validate: function(code) { 9 | if (/\)[^\s)]/.test(code)) return false 10 | code = this.desugar(code) 11 | return /^((SQRT(1_)?2|LOG10E|LN(2|10)|E|PI|random|abs|sqrt|cbrt|round|a?tan(h|2)?|log|exp|a?sinh?|a?cosh?|floor|ceil|int|trunc|min|max|pow|sign|pick|put|dup|drop|swap|tt|t|-?\d+\.\d+|-?\d+|>>>?|<<|&&|\|\||[-\/+*=&^|~><%])(?: |$))+/.test( 12 | code 13 | ) 14 | }, 15 | interpret: function(code, t = 0, tt = 0) { 16 | if (!this.validate(code)) throw Error('invalid code') 17 | code = this.desugar(code) 18 | for (const i of code.split(/\s+/g)) { 19 | if (/^-?\d+/.test(i)) { 20 | this.stack.push(Number(i)) 21 | } else { 22 | let x 23 | let y 24 | switch (i) { 25 | case 't': 26 | this.stack.push(t) 27 | break 28 | case 'tt': 29 | this.stack.push(tt) 30 | break 31 | case '+': 32 | this.stack.push(this.stack.pop() + this.stack.pop()) 33 | break 34 | case '-': 35 | this.stack.push(-this.stack.pop() + this.stack.pop()) 36 | break 37 | case '*': 38 | this.stack.push(this.stack.pop() * this.stack.pop()) 39 | break 40 | case '/': 41 | this.stack.push((1 / this.stack.pop()) * this.stack.pop()) 42 | break 43 | case '%': 44 | x = this.stack.pop() 45 | this.stack.push(this.stack.pop() % x) 46 | break 47 | case '~': 48 | this.stack.push(~this.stack.pop()) 49 | break 50 | case '>>': 51 | x = this.stack.pop() 52 | this.stack.push(this.stack.pop() >> x) 53 | break 54 | case '>>>': 55 | x = this.stack.pop() 56 | this.stack.push(this.stack.pop() >>> x) 57 | break 58 | case '<<': 59 | x = this.stack.pop() 60 | this.stack.push(this.stack.pop() << x) 61 | break 62 | case '&&': 63 | x = this.stack.pop() 64 | this.stack.push(this.stack.pop() && x) 65 | break 66 | case '||': 67 | x = this.stack.pop() 68 | this.stack.push(this.stack.pop() || x) 69 | break 70 | case '&': 71 | this.stack.push(this.stack.pop() & this.stack.pop()) 72 | break 73 | case '|': 74 | this.stack.push(this.stack.pop() | this.stack.pop()) 75 | break 76 | case '^': 77 | this.stack.push(this.stack.pop() ^ this.stack.pop()) 78 | break 79 | case '>': 80 | this.stack.push( 81 | this.stack.pop() > this.stack.pop() ? 0xffffffff : 0 82 | ) 83 | break 84 | case '<': 85 | this.stack.push( 86 | this.stack.pop() < this.stack.pop() ? 0xffffffff : 0 87 | ) 88 | break 89 | case '=': 90 | this.stack.push( 91 | this.stack.pop() === this.stack.pop() ? 0xffffffff : 0 92 | ) 93 | break 94 | case 'int': 95 | this.stack.push(parseInt(this.stack.pop())) 96 | break 97 | case 'abs': 98 | case 'floor': 99 | case 'round': 100 | case 'sqrt': 101 | case 'ceil': 102 | case 'sin': 103 | case 'cos': 104 | case 'tan': 105 | case 'sinh': 106 | case 'cosh': 107 | case 'tanh': 108 | case 'asin': 109 | case 'acos': 110 | case 'asinh': 111 | case 'acosh': 112 | case 'atan': 113 | case 'atanh': 114 | case 'atan2': 115 | case 'cbrt': 116 | case 'sign': 117 | case 'trunc': 118 | this.stack.push(Math[i](this.stack.pop())) 119 | break 120 | case 'log': 121 | case 'exp': 122 | case 'min': 123 | case 'max': 124 | case 'pow': 125 | x = this.stack.pop() 126 | this.stack.push(Math[i](this.stack.pop(), x)) 127 | break 128 | case 'dup': 129 | x = this.stack.pop() 130 | this.stack.push(x) 131 | this.stack.push(x) 132 | break 133 | case 'swap': 134 | x = this.stack.pop() 135 | y = this.stack.pop() 136 | this.stack.push(x) 137 | this.stack.push(y) 138 | break 139 | case 'drop': 140 | this.stack.pop() 141 | break 142 | case 'pick': 143 | y = this.stack.pop() 144 | this.stack.push( 145 | this.stack.data[(this.stack.pointer - y - 1) & 0xff] 146 | ) 147 | break 148 | case 'put': 149 | x = this.stack.pop() 150 | y = this.stack.pop() 151 | this.stack.data[(this.stack.pointer - y) & 0xff] = x 152 | this.stack.push(y) 153 | break 154 | case 'E': 155 | case 'LN2': 156 | case 'LN10': 157 | case 'LOG10E': 158 | case 'PI': 159 | case 'SQRT1_2': 160 | case 'SQRT2': 161 | case 'random': 162 | this.stack.push(Math[i]) 163 | break 164 | } 165 | } 166 | } 167 | return this.stack.pop() 168 | }, 169 | stack: { 170 | data: Array(256).fill(0), 171 | pointer: 0, 172 | pop: function() { 173 | this.pointer = (this.pointer - 1) & 0xff 174 | return this.data[this.pointer] 175 | }, 176 | push: function(val) { 177 | this.data[this.pointer] = val 178 | this.pointer = (this.pointer + 1) & 0xff 179 | }, 180 | reset: function() { 181 | this.data.fill(0) 182 | this.pointer = 0 183 | }, 184 | }, 185 | glitchOpcodes: { 186 | a: 't', 187 | b: 'put', 188 | c: 'drop', 189 | d: '*', 190 | e: '/', 191 | f: '+', 192 | g: '-', 193 | h: '%', 194 | j: '<<', 195 | k: '>>', 196 | l: '&', 197 | m: '|', 198 | n: '^', 199 | o: '~', 200 | p: 'dup', 201 | q: 'pick', 202 | r: 'swap', 203 | s: '<', 204 | t: '>', 205 | u: '=', 206 | '!': '\n', 207 | }, 208 | /** Tests to make sure the RPN code only contains Glitch compatible keywords */ 209 | isValidGlitchCode: function(code) { 210 | return /^((pick|put|dup|drop|swap|t|-?\d+|>>|<<||[-\/+*=&^|~><%])(?:\s+|$))+/.test( 211 | this.desugar(code) 212 | ) 213 | }, 214 | isValidWasmRPN: function(code) { 215 | return /^((drop|tt?|-?\d+|>>>?|<<||[-\/+*=&^|~><%])(?:\s+|$))+/.test( 216 | this.desugar(code) 217 | ) 218 | }, 219 | toGlitchURL: function(code, name = '') { 220 | code = this.desugar(code) 221 | if (!this.isValidGlitchCode(code)) 222 | throw Error("Can't be converted to glitch URL") 223 | return `glitch://${name}!`.concat( 224 | Array.from( 225 | code.match( 226 | /(pick|put|dup|drop|swap|t|-?\d+|>>|<<|\n||[-\/+*=&^|~><%])(\s+|$)/gi 227 | ) 228 | ) 229 | .map(v => v.trim()) 230 | .map((v, i, a) => { 231 | if (/\d/.test(v)) { 232 | const x = v.toString(16).toUpperCase() 233 | return /\d/.test(a[i + 1]) ? x + '.' : x 234 | } else { 235 | return v 236 | } 237 | }) 238 | .map(v => 239 | Object.values(this.glitchOpcodes).includes(v) 240 | ? Object.keys(this.glitchOpcodes)[ 241 | Object.values(this.glitchOpcodes).indexOf(v) 242 | ] 243 | : v 244 | ) 245 | .join('') 246 | ) 247 | }, 248 | fromGlitchURL: function(glitch) { 249 | const [, name, code] = glitch.match(/^(?:glitch:\/\/)?([^!]*)!(.*)$/) 250 | return [ 251 | name, 252 | code 253 | .match(/[\dA-F]+|[a-hj-u!]/g) 254 | .map(c => 255 | /[\dA-F]+/.test(c) ? parseInt(c, 16) : this.glitchOpcodes[c] 256 | ) 257 | .join(' ') 258 | .replace(/\n /g, '\n'), 259 | ] 260 | }, 261 | glitchOpnames: { 262 | '+': 'add', 263 | '-': 'subtract', 264 | '*': 'multiply', 265 | '/': 'divide', 266 | '%': 'modulo', 267 | '~': 'bitwiseInvert', 268 | '>>': 'shiftRight', 269 | '<<': 'shiftLeft', 270 | '&': 'bitwiseAnd', 271 | '|': 'bitwiseOr', 272 | '^': 'bitwiseXor', 273 | '=': 'equal', 274 | '>': 'greaterThan', 275 | '<': 'lessThan', 276 | drop: 'drop', 277 | dup: 'dup', 278 | swap: 'swap', 279 | pick: 'pick', 280 | put: 'put', 281 | }, 282 | /** 283 | * Converts s-expressions to RPN stack ordered instructions 284 | * 285 | * @param {string} code 286 | * @returns {string} the desugared code string 287 | * @example 288 | * RPN.desugar('(+ 1 1)') // returns '1 1 +' 289 | * RPN.desugar('(* (+ 2 3) (- 5 1))') // return '3 2 + 5 1 - *' 290 | **/ 291 | desugar: function(code) { 292 | while (/\(.*\)/.test(code)) { 293 | code = code.replace(/\(([^(]+?) ([^()]+)\)/g, '$2 $1') 294 | } 295 | return code 296 | }, 297 | get glitchMachine() { 298 | delete this.glitchMachine 299 | WebAssembly.instantiateStreaming(fetch('./src/rpn.wasm')) 300 | .then(results => (this.glitchMachine = results.instance.exports)) 301 | }, 302 | set glitchMachine(x) { 303 | delete this.glitchMachine 304 | this.glitchMachine = x 305 | }, 306 | /** 307 | * Interpret Glitch semantics code with wasm interpreter. 308 | * 309 | * @param {string} code the code 310 | * @param {number} [t = 0] value of t to be used by the passed code 311 | * @param {string} [outputType = 'byte'] 312 | **/ 313 | glitchInterpret: function(code, t = 0, outputType = 'byte') { 314 | code = this.desugar(code) 315 | if (this.isValidGlitchCode(code) && this.glitchMachine) { 316 | for (const inst of code.split(/\s+/)) { 317 | if (inst === 't') { 318 | this.glitchMachine.push(Number(t)) 319 | } else if (inst in this.glitchOpnames) { 320 | this.glitchMachine[this.glitchOpnames[inst]]() 321 | } else { 322 | this.glitchMachine.push(Number(inst)) 323 | } 324 | } 325 | switch (outputType) { 326 | case 'float32': 327 | return this.glitchMachine.popFloat32() 328 | case 'float32byte': 329 | return this.glitchMachine.popFloat32Byte() 330 | case 'uint32': 331 | return this.glitchMachine.popUint32() 332 | case 'byte': 333 | default: 334 | return this.glitchMachine.popByte() 335 | } 336 | } 337 | }, 338 | isValidGlitchURL: function(url) { 339 | return /^(glitch:\/\/)?[^!]*![a-hj-u!A-F\d.]+$/.test(url) 340 | }, 341 | glitchToWat: { 342 | '+': { text: 'i32.add', opcode: [0x6a] }, 343 | '-': { text: 'i32.sub', opcode: [0x6b] }, 344 | '*': { text: 'i32.mul', opcode: [0x6c] }, 345 | '/': { text: 'i32.div_u', opcode: [0x6e] }, 346 | '%': { text: 'i32.rem_u', opcode: [0x70] }, 347 | '~': { text: 'i32.const -1\ni32.xor', opcode: [0x41, 0x7f, 0x73] }, 348 | '>>': { text: 'i32.shr_s', opcode: [0x75] }, 349 | '>>>': { text: 'i32.shr_u', opcode: [0x76] }, 350 | '<<': { text: 'i32.shl', opcode: [0x74] }, 351 | '&': { text: 'i32.and', opcode: [0x71] }, 352 | '|': { text: 'i32.or', opcode: [0x72] }, 353 | '^': { text: 'i32.xor', opcode: [0x73] }, 354 | '=': { text: 'i32.eq', opcode: [0x46] }, 355 | '>': { text: 'i32.gt_u', opcode: [0x4b] }, 356 | '<': { text: 'i32.lt_u', opcode: [0x49] }, 357 | drop: { text: 'drop', opcode: [0x1a] }, 358 | // TODO: swap, dup, pick, put 359 | t: { text: 'local.get $t', opcode: [0x20, 0x0] }, 360 | tt: { text: 'local.get $tt', opcode: [0x20, 0x1] }, 361 | }, 362 | toWat: function(code) { 363 | return this.wrapWat(this.tokensToWat(code)) 364 | }, 365 | tokensToWat: function(code) { 366 | return this.desugar(code.trim()) 367 | .split(/\s+/) 368 | .map(token => { 369 | if (token in this.glitchToWat) { 370 | return this.glitchToWat[token].text 371 | } else if (/^\d+$/.test(token)) { 372 | return `i32.const ${token}` 373 | } else { 374 | throw Error(`Could not parse ${token}`) 375 | } 376 | }) 377 | .join('\n') 378 | }, 379 | wrapWat: function(code) { 380 | return `(module (type $t0 (func (param i32 i32) (result i32))) (func $bytebeat 381 | (export "bytebeat") (type $t0) (param $t i32) (param $tt i32) (result i32) 382 | ${code}))` 383 | }, 384 | wasmHeader: [ 385 | 0, 386 | 0x61, 387 | 0x73, 388 | 0x6d, // WASM_BINARY_MAGIC 389 | 1, 390 | 0, 391 | 0, 392 | 0, // WASM_BINARY_VERSION 393 | // section "Type" (1) 394 | 1, // section code 395 | 7, // section size 396 | 1, // num types 397 | 0x60, // func 398 | 2, // num params 399 | 0x7f, // i32 400 | 0x7f, // i32 401 | 1, // num results 402 | 0x7f, // i32 403 | // section "Function" (3) 404 | 3, // section code 405 | 2, // section size 406 | 1, // num functions 407 | 0, // function 0 signature index 408 | // section "Export" (7) 409 | 7, // section code 410 | 0xc, // section size 411 | 1, // num exports 412 | 8, // string length 413 | 0x62, 414 | 0x79, 415 | 0x74, 416 | 0x65, 417 | 0x62, 418 | 0x65, 419 | 0x61, 420 | 0x74, // 'bytebeat' 421 | 0, // export kind 422 | 0, // export func index 423 | 0xa, // section code 424 | ], 425 | wasmFooter: [ 426 | 0, // section code 427 | 0x1e, // section size 428 | 4, // string length 429 | 0x6e, 430 | 0x61, 431 | 0x6d, 432 | 0x65, // custom section name 433 | 1, // function name type 434 | 0xb, // subsection size (guess) 435 | 1, // num functions 436 | 0, // function index 437 | 8, // string length 438 | 0x62, 439 | 0x79, 440 | 0x74, 441 | 0x65, 442 | 0x62, 443 | 0x65, 444 | 0x61, 445 | 0x74, // "bytebeat" func name 0 446 | 2, // local name type 447 | 0xa, // subsection size 448 | 1, // num functions 449 | 0, // function index 450 | 2, // num locals 451 | 0, // local index 452 | 1, // string length 453 | 0x74, // t : local name 0 454 | 1, // local index 455 | 2, // string length 456 | 0x74, 457 | 0x74, // tt : local name 1 458 | ], 459 | toWasmBinary: function(code) { 460 | if (!this.isValidWasmRPN(code)) throw Error() 461 | let func = [0] // local decl count = 0 462 | for (const token of this.desugar(code).split(/\s+/g)) { 463 | if (/\d+/.test(token)) { 464 | func = func.concat(0x41, ...new LEB128(token)) 465 | } else if (token in this.glitchToWat) { 466 | func = func.concat(...this.glitchToWat[token].opcode) 467 | } else { 468 | throw Error(`Could not understand ${token}`) 469 | } 470 | } 471 | func.push(0xb) 472 | const funcLen = new LEB128(func.length) 473 | 474 | return new Uint8Array([ 475 | ...this.wasmHeader, 476 | ...new LEB128(func.length + funcLen.length + 1), 477 | 1, 478 | ...funcLen, 479 | ...func, 480 | ...this.wasmFooter 481 | ]) 482 | }, 483 | } 484 | 485 | export { RPN as default } 486 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | import { BytebeatNote } from './src/bytebeat-note.js' 2 | import { validateBytebeat } from './src/bytebeat-utils.js' 3 | import { Metronome } from './src/metronome.js' 4 | import { MIDINumber } from './src/midinumber.js' 5 | import { NoteMap } from './src/note-map.js' 6 | import { OscillatorNote } from './src/oscillator-note.js' 7 | import { RPN } from './src/rpn.js' 8 | import { ComplexNumber, evaluate, invert, mathML, parse, validate as validateFormula } from './src/maths.js' 9 | 10 | if (window.WebAssembly) { 11 | import('./src/build-wabt.js').then((module) => { 12 | const wat = document.getElementById('wasm-wat-code') 13 | const watInput = async () => { 14 | try { 15 | const mod = await module.buildWabt(`(module (type $t0 (func (param 16 | i32 i32) (result i32))) (func $bytebeat (export "bytebeat") (type $t0) 17 | (param $t i32) (param $tt i32) (result i32) ${wat.value}))`) 18 | wasmModule = mod.module 19 | wat.setCustomValidity('') 20 | } catch (e) { 21 | wat.setCustomValidity('Invalid wasm') 22 | } 23 | } 24 | wat.oninput = watInput 25 | 26 | const rpn = document.getElementById('wasm-rpn-code') 27 | const rpnInput = async () => { 28 | try { 29 | const bin = RPN.toWasmBinary(rpn.value) 30 | if (WebAssembly.validate(bin)) { 31 | wasmModule = await WebAssembly.compile(bin) 32 | rpn.setCustomValidity('') 33 | } else { 34 | rpn.setCustomValidity('Invalid wasm') 35 | } 36 | } catch { 37 | rpn.setCustomValidity('Invalid wasm') 38 | } 39 | } 40 | rpn.oninput = rpnInput 41 | 42 | const codeBlocks = [wat, rpn] 43 | const noProp = (ev) => ev.stopPropagation() 44 | for (const b of codeBlocks) b.addEventListener('keydown', noProp) 45 | 46 | const wasmLanguage = document.getElementById('wasm-language') 47 | wasmLanguage.addEventListener('change', (ev) => 48 | changeCurrentView(ev.target.value, 'wasm-language', 'wasm-code-blocks') 49 | ) 50 | document.getElementById(wasmLanguage.value).oninput() 51 | }) 52 | } else { 53 | document.querySelector('option[value="wasmbeat"]').remove() 54 | } 55 | 56 | class Preset { 57 | /** Type for storing envelope and oscillator preset information for recalling 58 | * user defined presets and UI persistence between sessions. 59 | * @param {string} name 60 | * @param {object} envelope 61 | * @param {number} [envelope.attack=0.2] 62 | * @param {number} [envelope.decay=0.2] 63 | * @param {number} [envelope.sustain=0.4] 64 | * @param {number} [envelope.release=0.1] 65 | * @param {object[]} oscillators 66 | * @param {string} [oscillators.waveform='sine'] 67 | * Options are 'sine', 'square', 'triangle', 'sawtooth', 'custom' 68 | * @param {number} [oscillators.gain=0.5] 69 | * @param {number} [oscillators.detune=0] 70 | * @param {number} [oscillators.note-offset=0] 71 | * @param {number} [oscillators.octave=0] 72 | * @param {boolean} [oscillators.invert-phase=false] 73 | * @param {string} [oscillators.bytebeatCode=''] 74 | * @param {'additive-oscillators' | 'bytebeat' | 'harmonic-series' | 'wasmbeat'} type 75 | */ 76 | constructor(name, envelope, oscillators, type = 'additive-oscillators') { 77 | if (!(envelope && oscillators)) { 78 | throw new TypeError( 79 | 'Preset: Constructor is missing necessary initialization parameters' 80 | ) 81 | } 82 | this.name = name 83 | this.envelope = envelope 84 | this.oscillators = oscillators 85 | this.type = type 86 | } 87 | } 88 | 89 | /** Marshall the UI information 90 | * @return {Preset} 91 | */ 92 | function getPresetInfo() { 93 | const type = document.getElementById('source-select').value 94 | const oscs = [] 95 | if (type === 'harmonic-series') { 96 | const o = {} 97 | for (const id of ['harmonic-count', 'harmonic-function']) { 98 | o[id] = document.getElementById(id).value 99 | } 100 | oscs.push(o) 101 | } else if (type === 'bytebeat') { 102 | oscs.push({ 103 | bytebeatCode: document.getElementById('bytebeat-code').value, 104 | bytebeatMode: document.getElementById('bytebeat-mode').value, 105 | }) 106 | } else if (type === 'wasmbeat') { 107 | const code = document.querySelector('#wasm-code-blocks textarea:not(.hidden)') 108 | oscs.push({ id: code.id, value: code.value }) 109 | for (const { id, value } of document.querySelectorAll('#wasm-options select')) { 110 | oscs.push({ id, value }) 111 | } 112 | } else { 113 | for (const osc of document.querySelectorAll('.oscillator')) { 114 | const o = {} 115 | o.waveform = osc.querySelector('.waveform').value 116 | for (const param of osc.querySelectorAll('input')) { 117 | o[param.className] = param.value 118 | } 119 | o['invert-phase'] = osc.querySelector('.invert-phase').checked 120 | oscs.push(o) 121 | } 122 | } 123 | return new Preset( 124 | document.getElementById('preset-name').value, 125 | envelopeElement.envelope, 126 | oscs, 127 | type 128 | ) 129 | } 130 | 131 | /** Adds a new oscillator panel to the UI window 132 | * @param {Preset} [preset] 133 | */ 134 | function addOscillator(preset) { 135 | const oscillator = document.importNode( 136 | document.getElementById('oscillator-template').content, true) 137 | const panel = document.getElementById('oscillator-panel') 138 | oscillator.querySelector('.remove-oscillator') 139 | .addEventListener('click', (e) => panel.removeChild(e.target.parentElement)) 140 | oscillator.querySelector('.gain') 141 | .addEventListener('dblclick', (e) => { e.target.value = 0.5 }) 142 | if (preset) { 143 | for (const pp in preset) { 144 | if (pp === 'invert-phase') { 145 | oscillator.querySelector('.invert-phase').checked = true 146 | } else { 147 | oscillator.querySelector('.' + pp).value = preset[pp] 148 | } 149 | } 150 | } 151 | panel.appendChild(oscillator) 152 | } 153 | 154 | /** Saves a preset to the custom presets list. 155 | * @throws Unnamed presets can't be saved 156 | */ 157 | function addPreset() { 158 | if (!document.getElementById('preset-name').value) { 159 | throw new Error("Unnamed preset can't be saved") 160 | } 161 | customPresets.push(getPresetInfo()) 162 | updateCustomPresets(customPresets, document.getElementById('custom-presets')) 163 | } 164 | 165 | /** Remove the currently selected preset */ 166 | function removePreset() { 167 | const selected = document.getElementById('preset-list').selectedOptions[0] 168 | if (selected.parentElement.label === 'Custom Presets') { 169 | customPresets.splice(selected.value, 1) 170 | updateCustomPresets(customPresets, document.getElementById('custom-presets')) 171 | } 172 | } 173 | 174 | /** Load the settings of a preset to the page 175 | * @param {Preset} preset The preset to load 176 | */ 177 | function loadPreset({ envelope, oscillators, type }) { 178 | changeCurrentView( 179 | type || 'additive-oscillators', 180 | 'source-select', 181 | 'audio-sources' 182 | ) 183 | switch (type) { 184 | case 'bytebeat': 185 | document.getElementById('bytebeat-code').value = oscillators[0].bytebeatCode 186 | document.getElementById('bytebeat-mode').value = oscillators[0].bytebeatMode 187 | break 188 | case 'wasmbeat': 189 | for (const { id, value } of oscillators) { 190 | const el = document.getElementById(id) 191 | if (el) el.value = value 192 | } 193 | break 194 | case 'harmonic-series': 195 | for (const [id, value] of Object.entries(oscillators[0])) { 196 | document.getElementById(id).value = value 197 | } 198 | break 199 | case 'additive-oscillators': 200 | default: 201 | removeChildren(document.getElementById('oscillator-panel')) 202 | oscillators.forEach(addOscillator) 203 | } 204 | envelopeElement.envelope = envelope 205 | } 206 | 207 | let controller, 208 | playingNotes = new NoteMap(), 209 | sustainingNotes = new NoteMap(), 210 | sostenutoNotes = new NoteMap(), 211 | currentlyHeldKeys = new NoteMap(), 212 | // pitchBend = 0, 213 | customPresets = [], 214 | wasmModule 215 | const removeChildren = (el) => { 216 | while (el.firstChild) el.removeChild(el.firstChild) 217 | }, 218 | audio = new AudioContext(), 219 | pedals = { sustain: false, sostenuto: false, soft: false }, 220 | panner = new StereoPannerNode(audio), 221 | masterGain = new GainNode(audio, { gain: 0.5 }), 222 | // masterLevel = new AnalyserNode(audio), 223 | metronome = new Metronome(masterGain), 224 | limiter = new DynamicsCompressorNode(audio, { 225 | attack: 0, 226 | knee: 0, 227 | ratio: 20, 228 | release: 0.01, 229 | threshold: -2, 230 | }), 231 | envelopeElement = document.querySelector('websynth-envelope'), 232 | keyboardKeymap = { 233 | "Digit0": 75, 234 | "Digit2": 61, 235 | "Digit3": 63, 236 | "Digit5": 66, 237 | "Digit6": 68, 238 | "Digit7": 70, 239 | "Digit9": 73, 240 | "KeyB": 55, 241 | "KeyC": 52, 242 | "KeyD": 51, 243 | "KeyE": 64, 244 | "KeyG": 54, 245 | "KeyH": 56, 246 | "KeyI": 72, 247 | "KeyJ": 58, 248 | "KeyL": 61, 249 | "KeyM": 59, 250 | "KeyN": 57, 251 | "KeyO": 74, 252 | "KeyP": 76, 253 | "KeyQ": 60, 254 | "KeyR": 65, 255 | "KeyS": 49, 256 | "KeyT": 67, 257 | "KeyU": 71, 258 | "KeyV": 53, 259 | "KeyW": 62, 260 | "KeyX": 50, 261 | "KeyY": 69, 262 | "KeyZ": 48, 263 | "Semicolon": 63, 264 | "Equal": 78, 265 | "Comma": 60, 266 | "Period": 62, 267 | "Slash": 64, 268 | "BracketLeft": 77, 269 | "Backslash": 47, 270 | "BracketRight": 79, 271 | "IntlBackslash": 47, 272 | }, 273 | factoryPresets = [ 274 | new Preset( 275 | 'Sinewave', 276 | { attack: 0.2, decay: 0.2, sustain: 0.4, release: 0.3 }, 277 | [{ gain: 0.7 }] 278 | ), 279 | new Preset( 280 | 'Bowed Glass', 281 | { attack: 0.62, decay: 0.15, sustain: 0.42, release: 0.32 }, 282 | [ 283 | { detune: -5 }, 284 | { detune: 5, 'invert-phase': true }, 285 | { waveform: 'triangle', octave: 1, gain: 0.2 }, 286 | ] 287 | ), 288 | new Preset( 289 | 'Church Organ', 290 | { attack: 0.28, decay: 0.35, sustain: 0.29, release: 0.18 }, 291 | [ 292 | { octave: -1, gain: 0.35 }, 293 | { detune: 2, 'note-offset': 7, gain: 0.25 }, 294 | { gain: 0.2 }, 295 | { octave: 1, gain: 0.2 }, 296 | { detune: 2, 'note-offset': 7, octave: 1, gain: 0.2 }, 297 | { octave: 2, gain: 0.15 }, 298 | { detune: -14, 'note-offset': 4, octave: 2, gain: 0.15 }, 299 | { detune: 2, 'note-offset': 7, octave: 2, gain: 0.15 }, 300 | { octave: 3, gain: 0.12 }, 301 | ] 302 | ), 303 | new Preset( 304 | 'Sierpinski Harmony', 305 | { attack: 0, decay: 0.15, sustain: 0.75, release: 0.04 }, 306 | [ 307 | { 308 | bytebeatCode: 't & t >> 8', 309 | bytebeatMode: 'byte', 310 | }, 311 | ], 312 | 'bytebeat' 313 | ), 314 | new Preset( 315 | 'Synced Sierpinski', 316 | { attack: 0, decay: 0.15, sustain: 0.75, release: 0.04 }, 317 | [ 318 | { 319 | bytebeatCode: 't & tt >> 8', 320 | bytebeatMode: 'byte', 321 | }, 322 | ], 323 | 'bytebeat' 324 | ), 325 | new Preset( 326 | 'Wasm Sierpinski', 327 | { attack: 0, decay: 0.15, sustain: 0.75, release: 0.04 }, 328 | [ 329 | { 330 | wasmbeatCode: 331 | 'local.get $t\nlocal.get $t\ni32.const 8\ni32.shr_u\ni32.and', 332 | wasmbeatLanguage: 'wat', 333 | }, 334 | ], 335 | 'wasmbeat' 336 | ), 337 | new Preset( 338 | 'Headachegoldfish', 339 | { attack: 0, decay: 0.15, sustain: 0.75, release: 0.04 }, 340 | [ 341 | { 342 | bytebeatCode: 343 | 'int("HEADACHEGOLDFISH",(tt>>10)%16)*(t&~7&0x1e70>>((tt>>15)%8))', 344 | bytebeatMode: 'byte', 345 | }, 346 | ], 347 | 'bytebeat' 348 | ), 349 | // new Preset('', { attack: 0, decay: 0.15, sustain: 0.75, release: 0.04 }, [ 350 | // {}, 351 | // ]), 352 | ] 353 | 354 | /** Release all currently playing notes */ 355 | function releaseAllNotes() { 356 | playingNotes.releaseAll() 357 | updateChordDisplay() 358 | } 359 | 360 | /** Stop all sound immediately */ 361 | function stopAllSound() { 362 | for (const ns of [playingNotes, sustainingNotes, sostenutoNotes]) { 363 | ns.stopAll() 364 | } 365 | updateChordDisplay() 366 | } 367 | 368 | function channelMode(event) { 369 | switch (event.controller.number) { 370 | case WebMidi.MIDI_CHANNEL_MODE_MESSAGES.allsoundoff: 371 | stopAllSound() 372 | break 373 | case WebMidi.MIDI_CHANNEL_MODE_MESSAGES.allnotesoff: 374 | releaseAllNotes() 375 | break 376 | default: 377 | break 378 | } 379 | } 380 | 381 | function controlChange(event) { 382 | switch (event.controller.number) { 383 | case WebMidi.MIDI_CONTROL_CHANGE_MESSAGES.holdpedal: 384 | case WebMidi.MIDI_CONTROL_CHANGE_MESSAGES.hold2pedal: 385 | sustainPedalEvent(event) 386 | break 387 | case WebMidi.MIDI_CONTROL_CHANGE_MESSAGES.modulationwheelcoarse: 388 | case WebMidi.MIDI_CONTROL_CHANGE_MESSAGES.sustenutopedal: 389 | sostenutoPedalEvent(event) 390 | break 391 | case WebMidi.MIDI_CONTROL_CHANGE_MESSAGES.softpedal: 392 | pedals.soft = event.value > 63 393 | break 394 | default: 395 | console.log(event) 396 | break 397 | } 398 | } 399 | 400 | /** Sync the chord displayed in the UI with the currently playing notes */ 401 | function updateChordDisplay() { 402 | const notes = [...playingNotes.keys()].sort((a, b) => a - b), 403 | sharp = document.getElementById('sharp-or-flat').checked, 404 | short = MIDINumber.toChord(notes, sharp, false), 405 | long = MIDINumber.toChord(notes, sharp, true), 406 | chord = short === long ? short : short + ' : ' + long 407 | document.getElementById('chord-name').value = chord 408 | return chord 409 | } 410 | 411 | /** Audio source functions for different audio generation techniques */ 412 | const noteSources = { 413 | 'additive-oscillators': { 414 | class: OscillatorNote, 415 | oscParams: (midiNum) => { 416 | const oscParams = [] 417 | for (const panel of document.querySelectorAll('.oscillator')) { 418 | oscParams.push({ 419 | type: panel.querySelector('.waveform').value, 420 | detune: panel.querySelector('.detune').value, 421 | frequency: MIDINumber.toFrequency( 422 | Number(panel.querySelector('.note-offset').value) + 423 | midiNum + 424 | Number(document.getElementById('note-offset').value) + 425 | (Number(panel.querySelector('.octave').value) + 426 | Number(document.getElementById('octave').value)) * 427 | 12 428 | ), 429 | gain: 430 | panel.querySelector('.gain').value * 431 | (panel.querySelector('.invert-phase').checked ? -1 : 1), 432 | }) 433 | } 434 | return oscParams 435 | }, 436 | }, 437 | 'harmonic-series': { 438 | class: OscillatorNote, 439 | oscParams: (midiNum) => { 440 | const oscParams = [] 441 | const count = document.getElementById('harmonic-count').value 442 | const real = new Float32Array(count + 1) 443 | real[1] = 1 444 | const imag = new Float32Array(count + 1) 445 | const N = new ComplexNumber(count) 446 | const func = document.getElementById('harmonic-function') 447 | try { 448 | const expr = ['/', new ComplexNumber(1), parse(func.value)] 449 | let x = new ComplexNumber(1, 0) 450 | for (let k = 1; k <= count; ++k) { 451 | x = evaluate(expr, { k: new ComplexNumber(k), N }) 452 | real[k + 1] = x.re 453 | imag[k + 1] = x.im 454 | } 455 | if (real.some(Number.isNaN)) { 456 | func.setCustomValidity('Invalid') 457 | return 458 | } 459 | func.setCustomValidity('') 460 | oscParams.push({ 461 | type: 'custom', real, imag, 462 | frequency: MIDINumber.toFrequency( 463 | midiNum + 464 | Number(document.getElementById('note-offset').value) + 465 | Number(document.getElementById('octave').value) * 12 466 | ), 467 | gain: 1, 468 | }) 469 | return oscParams 470 | } catch (error) { 471 | func.setCustomValidity('Invalid') 472 | throw error 473 | } 474 | 475 | }, 476 | }, 477 | bytebeat: { 478 | class: BytebeatNote, 479 | oscParams: (midiNum) => { 480 | return [ 481 | { 482 | bytebeat: document.getElementById('bytebeat-code').value, 483 | frequency: MIDINumber.toFrequency( 484 | midiNum + 485 | Number(document.getElementById('note-offset').value) + 486 | (Number(document.getElementById('octave').value) + 8) * 12 487 | ), 488 | tempo: Number(document.getElementById('tempo').value), 489 | floatMode: document.getElementById('bytebeat-mode').value === 'float', 490 | }, 491 | ] 492 | }, 493 | }, 494 | wasmbeat: { 495 | class: BytebeatNote, 496 | oscParams: (midiNum) => { 497 | return [ 498 | { 499 | module: wasmModule, 500 | frequency: MIDINumber.toFrequency( 501 | midiNum + 502 | Number(document.getElementById('note-offset').value) + 503 | (Number(document.getElementById('octave').value) + 8) * 12 504 | ), 505 | tempo: Number(document.getElementById('tempo').value), 506 | floatMode: false, 507 | }, 508 | ] 509 | }, 510 | }, 511 | } 512 | 513 | function noteOn(midiNum, velocity = 1) { 514 | const source = document.getElementById('source-select').value 515 | if ( 516 | (source === 'bytebeat' && !document.getElementById('bytebeat-code').validity.valid) || 517 | (source === 'wasmbeat' && !wasmModule) || 518 | (source === 'harmonic-series' && !document.getElementById('harmonic-function').validity.valid) 519 | ) { 520 | return 521 | } 522 | let noteParams = envelopeElement.envelope, 523 | oscParams = noteSources[source].oscParams(midiNum) 524 | if (!oscParams) return 525 | noteParams.triggerTime = audio.currentTime 526 | if (document.getElementById('velocity-sensitive').checked) { 527 | noteParams.velocity = velocity 528 | } 529 | if (pedals.soft) { 530 | noteParams.velocity *= 0.66 531 | noteParams.attack *= 1.333 532 | } 533 | playingNotes.set( 534 | midiNum, 535 | new noteSources[source].class(panner, noteParams, oscParams) 536 | ) 537 | updateChordDisplay() 538 | document.getElementById(MIDINumber.toScientificPitch(midiNum)).classList.add('keypress') 539 | } 540 | 541 | function noteOff(midiNum) { 542 | if (pedals.sustain && !sustainingNotes.has(midiNum)) { 543 | sustainingNotes.set(midiNum, playingNotes.release(midiNum)) 544 | } else if (playingNotes.has(midiNum)) { 545 | playingNotes.release(midiNum) 546 | } 547 | updateChordDisplay() 548 | document.getElementById(MIDINumber.toScientificPitch(midiNum)).classList.remove('keypress') 549 | } 550 | 551 | function sustainPedalEvent(ev) { 552 | if (pedals.sustain && ev.value < 64) { 553 | pedals.sustain = false 554 | sustainingNotes.forEach((n) => n.releaseNote()) 555 | sustainingNotes.clear() 556 | } else if (!pedals.sustain && ev.value > 63) { 557 | pedals.sustain = true 558 | } 559 | } 560 | 561 | function sostenutoPedalEvent(ev) { 562 | if (pedals.sostenuto && ev.value < 64) { 563 | pedals.sostenuto = false 564 | for (const n in sostenutoNotes) { 565 | sostenutoNotes[n].releaseNote() 566 | delete sostenutoNotes[n] 567 | } 568 | } else if (!pedals.sostenuto && ev.value > 63) { 569 | pedals.sostenuto = true 570 | sostenutoNotes = playingNotes 571 | playingNotes.clear() 572 | } 573 | } 574 | 575 | /** 576 | * Sets up event listeners to link MIDI events with the appropriate actions 577 | * @param {string|number} [channel='all'] MIDI channel to listen for events on 578 | */ 579 | function setupControllerListeners(channel = 'all') { 580 | controller.addListener('noteon', channel, (e) => 581 | noteOn(e.note.number, e.velocity) 582 | ) 583 | controller.addListener('noteoff', channel, (e) => noteOff(e.note.number)) 584 | controller.addListener('controlchange', channel, controlChange) 585 | controller.addListener('channelmode', channel, channelMode) 586 | } 587 | 588 | function setupDisplayKeyboard(maxKeys = 88, lowNote = 21) { 589 | removeChildren(document.getElementById('ebony')) 590 | removeChildren(document.getElementById('ivory')) 591 | const keyWidth = (12 / 7) * (95 / maxKeys) 592 | document.getElementById('keyboard').style.setProperty('--key-width', `${keyWidth}vw`) 593 | document.getElementById('keyboard').style.setProperty('--half-key', `${keyWidth / 2}vw`) 594 | const palette = generateColorPalette() 595 | const makeShadowKey = () => { 596 | const shadowKey = document.createElement('div') 597 | shadowKey.classList.add('invisible') 598 | shadowKey.classList.add('key') 599 | document.getElementById('ebony').appendChild(shadowKey) 600 | } 601 | makeShadowKey() 602 | for (let i = lowNote; i < lowNote + maxKeys; i++) { 603 | const elem = document.createElement('div') 604 | elem.classList.add('key') 605 | elem.id = MIDINumber.toScientificPitch(i) 606 | elem.midiNumber = i 607 | elem.onmousedown = (e) => noteOn(e.target.midiNumber) 608 | elem.onmouseleave = (e) => noteOff(e.target.midiNumber) 609 | elem.onmouseup = (e) => noteOff(e.target.midiNumber) 610 | elem.addEventListener('touchstart', (e) => { 611 | e.preventDefault() 612 | noteOn(e.target.midiNumber) 613 | }) 614 | elem.addEventListener('touchend', (e) => { 615 | e.preventDefault() 616 | noteOff(e.target.midiNumber) 617 | }) 618 | elem.addEventListener('touchcancel', (e) => { 619 | e.preventDefault() 620 | noteOff(e.target.midiNumber) 621 | }) 622 | elem.style.setProperty('--squish', palette[i % 12]) 623 | if (elem.id.includes('E') || elem.id.includes('B')) { 624 | makeShadowKey() 625 | } 626 | if (elem.id.includes('♯')) { 627 | document.getElementById('ebony').appendChild(elem) 628 | } else { 629 | document.getElementById('ivory').appendChild(elem) 630 | } 631 | } 632 | } 633 | 634 | function generateColorPalette(seed = Math.random() * Math.PI * 2) { 635 | const palette = [], 636 | j = (2 * Math.PI) / 3 637 | const magic = (f) => 638 | (187 + (Math.cos(f * 5) + Math.cos(f * 7)) * 32).toPrecision(3) 639 | for (let i = 0; i < 12; i++) { 640 | let f = i + seed, 641 | c = [] 642 | for (let k = 0; k < 3; k++) { 643 | c.push(magic((f += j))) 644 | } 645 | f -= j 646 | palette.push('rgb(' + c.join() + ')') 647 | } 648 | return palette 649 | } 650 | 651 | function setupKeypressKeymap() { 652 | document.addEventListener('keydown', ({ altKey, code, ctrlKey, repeat, shiftKey, target }) => { 653 | if ( 654 | Object.keys(keyboardKeymap).includes(code) && 655 | !altKey && !shiftKey && !ctrlKey && !repeat && 656 | !Object.values(currentlyHeldKeys).includes(keyboardKeymap[code]) && 657 | target.tagName !== 'INPUT' 658 | ) { 659 | currentlyHeldKeys[code] = keyboardKeymap[code] 660 | noteOn(keyboardKeymap[code]) 661 | } 662 | }) 663 | document.addEventListener('keyup', ({ code }) => { 664 | if (Object.keys(currentlyHeldKeys).includes(code)) { 665 | delete currentlyHeldKeys[code] 666 | noteOff(keyboardKeymap[code]) 667 | } 668 | }) 669 | /** @type {HTMLInputElement} */ 670 | const bb = document.getElementById('bytebeat-code') 671 | bb.addEventListener('input', () => { 672 | bb.setCustomValidity(validateBytebeat(bb.value) ? '' : 'Invalid bytebeat') 673 | }) 674 | } 675 | 676 | function setupGlobalEventListeners() { 677 | document.getElementById('master-gain').addEventListener('change', ({ target }) => { 678 | masterGain.gain.value = target.value 679 | }) 680 | document.getElementById('master-gain').addEventListener('dblclick', ({ target }) => { 681 | masterGain.gain.value = 0.5 682 | target.value = 0.5 683 | }) 684 | document.getElementById('panning').addEventListener('change', ({ target }) => { 685 | panner.pan.value = target.value 686 | }) 687 | document.getElementById('panning').addEventListener('dblclick', ({ target }) => { 688 | panner.pan.value = 0 689 | target.value = 0 690 | }) 691 | document.getElementById('metronome').addEventListener('change', ({ target }) => { 692 | target.checked ? metronome.start() : metronome.stop() 693 | }) 694 | document.getElementById('tempo').addEventListener('change', ({ target }) => { 695 | metronome.tempo = target.value 696 | }) 697 | document.getElementById('source-select').addEventListener('change', ({ target }) => 698 | changeCurrentView(target.value, 'source-select', 'audio-sources') 699 | ) 700 | } 701 | 702 | /** Hides all audio source UI except the one with the matching ID 703 | * @param {string} viewId 704 | */ 705 | function changeCurrentView(viewId, selectId, parentId) { 706 | const select = document.getElementById(selectId) 707 | if (select.value !== viewId) { 708 | select.value = viewId 709 | } 710 | for (const el of document.querySelectorAll(`#${parentId}>:not(nav)`)) { 711 | el.classList[el.id === viewId ? 'remove' : 'add']('hidden') 712 | } 713 | } 714 | 715 | /** Attach preset