diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 383c2ffa7..5e0c450d6 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -445,6 +445,19 @@ export const { coarse } = registerControl('coarse'); */ export const { drive } = registerControl('drive'); +/** + * Create byte beats with custom expressions + * + * @name byteBeatExpression + * @synonyms bbexpr + * + * @param {number | Pattern} byteBeatExpression pattern the output channels + * @example + * s("bytebeat").bbexpr('t*(t>>15^t>>66)') + * + */ +export const { byteBeatExpression, bbexpr } = registerControl('byteBeatExpression', 'bbexpr'); + /** * Allows you to set the output channels on the interface * diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 55b72d165..79c9093af 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -137,6 +137,7 @@ const defaultDefaultValues = { shapevol: 1, distortvol: 1, delay: 0, + byteBeatExpression: '0', delayfeedback: 0.5, delaytime: 0.25, orbit: 1, @@ -445,7 +446,7 @@ function mapChannelNumbers(channels) { return (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1); } -export const superdough = async (value, t, hapDuration) => { +export const superdough = async (value, t, hapDuration, cps) => { const ac = getAudioContext(); t = typeof t === 'string' && t.startsWith('=') ? Number(t.slice(1)) : ac.currentTime + t; let { stretch } = value; @@ -475,6 +476,7 @@ export const superdough = async (value, t, hapDuration) => { let { s = getDefaultValue('s'), bank, + byteBeatExpression, source, gain = getDefaultValue('gain'), postgain = getDefaultValue('postgain'), @@ -578,7 +580,7 @@ export const superdough = async (value, t, hapDuration) => { // get source AudioNode let sourceNode; if (source) { - sourceNode = source(t, value, hapDuration); + sourceNode = source(t, value, hapDuration, cps); } else if (getSound(s)) { const { onTrigger } = getSound(s); const onEnded = () => { diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index 9a1e4d150..1bd2ff8fc 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -144,6 +144,65 @@ export function registerSynthSounds() { { prebake: true, type: 'synth' }, ); + registerSound( + 'bytebeat', + (begin, value, onended) => { + const defaultBeats = ['(t%255 >= t/255%255)*255', '(t*(t*8%60 <= 300)|(-t)*(t*4%512 < 256))+t/400', 't']; + const { n } = value; + const { byteBeatExpression = defaultBeats[n % defaultBeats.length] } = value; + const ac = getAudioContext(); + + let { duration } = value; + const [attack, decay, sustain, release] = getADSRValues( + [value.attack, value.decay, value.sustain, value.release], + 'linear', + [0.001, 0.05, 0.6, 0.01], + ); + const holdend = begin + duration; + const end = holdend + release + 0.01; + const frequency = getFrequencyFromValue(value); + + let o = getWorklet( + ac, + 'byte-beat-processor', + { + frequency, + begin, + end, + }, + { + outputChannelCount: [2], + }, + ); + + o.port.postMessage(byteBeatExpression); + + let envGain = gainNode(1); + envGain = o.connect(envGain); + + getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear'); + + let timeoutNode = webAudioTimeout( + ac, + () => { + destroyAudioWorkletNode(o); + envGain.disconnect(); + onended(); + }, + begin, + end, + ); + + return { + node: envGain, + stop: (time) => { + timeoutNode.stop(time); + }, + }; + }, + { prebake: true, type: 'synth' }, + ); + registerSound( 'pulse', (begin, value, onended) => { diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs index 03d3a966f..b1cd1cb23 100644 --- a/packages/superdough/worklets.mjs +++ b/packages/superdough/worklets.mjs @@ -295,7 +295,7 @@ class LadderProcessor extends AudioWorkletProcessor { cutoff = (cutoff * 2 * _PI) / sampleRate; cutoff = cutoff > 1 ? 1 : cutoff; - const k = Math.min(8, resonance * 0.4); + const k = Math.min(8, resonance * 0.13); // drive makeup * resonance volume loss makeup let makeupgain = (1 / drive) * Math.min(1.75, 1 + k); @@ -761,3 +761,90 @@ class PulseOscillatorProcessor extends AudioWorkletProcessor { } registerProcessor('pulse-oscillator', PulseOscillatorProcessor); + +class ByteBeatProcessor extends AudioWorkletProcessor { + constructor() { + super(); + this.codeText = '0'; + this.port.onmessage = (event) => { + this.codeText = event.data + .trim() + .replace( + /^eval\(unescape\(escape(?:`|\('|\("|\(`)(.*?)(?:`|'\)|"\)|`\)).replace\(\/u\(\.\.\)\/g,["'`]\$1%["'`]\)\)\)$/, + (match, m1) => unescape(escape(m1).replace(/u(..)/g, '$1%')), + ); + }; + this.virtualRate = 112600; // target sample rate + this.t = null; + this.framebuffer = new Float32Array(Math.floor(sampleRate / 60)); + this.func = null; + } + + static get parameterDescriptors() { + return [ + { + name: 'begin', + defaultValue: 0, + max: Number.POSITIVE_INFINITY, + min: 0, + }, + { + name: 'frequency', + defaultValue: 440, + min: Number.EPSILON, + }, + { + name: 'detune', + defaultValue: 0, + min: Number.NEGATIVE_INFINITY, + max: Number.POSITIVE_INFINITY, + }, + { + name: 'end', + defaultValue: 0, + max: Number.POSITIVE_INFINITY, + min: 0, + }, + ]; + } + + process(inputs, outputs, params) { + if (this.disconnected) { + return false; + } + if (currentTime <= params.begin[0]) { + return true; + } + if (currentTime >= params.end[0]) { + return false; + } + if (this.t == null) { + this.t = params.begin[0] * this.virtualRate; + } + + let codeText = this.codeText; + this.func = Function('t', 'return ' + codeText); + const output = outputs[0]; + const tIncrement = this.virtualRate / sampleRate; + + for (let i = 0; i < output[0].length; i++) { + let t = Math.floor(this.t); + const detune = getParamValue(i, params.detune); + const freq = applySemitoneDetuneToFrequency(getParamValue(i, params.frequency), detune / 100); + + t = (t / (this.virtualRate / 256)) * freq; + + const funcValue = this.func(t); + let signal = (funcValue & 255) / 127.5 - 1; + const out = signal * 0.2; + for (let c = 0; c < output.length; c++) { + output[c][i] = out; + } + this.t = this.t + tIncrement; + } + + return true; // keep the audio processing going + } +} + +registerProcessor('byte-beat-processor', ByteBeatProcessor); diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index e7d6dbb18..b9cdb444b 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -1516,6 +1516,15 @@ exports[`runs examples > example "brandBy" example index 0 1`] = ` ] `; +exports[`runs examples > example "byteBeatExpression" example index 0 1`] = ` +[ + "[ 0/1 → 1/1 | s:bytebeat byteBeatExpression:t*(t>>15^t>>66) ]", + "[ 1/1 → 2/1 | s:bytebeat byteBeatExpression:t*(t>>15^t>>66) ]", + "[ 2/1 → 3/1 | s:bytebeat byteBeatExpression:t*(t>>15^t>>66) ]", + "[ 3/1 → 4/1 | s:bytebeat byteBeatExpression:t*(t>>15^t>>66) ]", +] +`; + exports[`runs examples > example "cat" example index 0 1`] = ` [ "[ 0/1 → 1/1 | note:e5 ]",