diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 6cac6e540..39c2df2f8 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -655,6 +655,15 @@ const generic_params = [ * .vib("<.5 1 2 4 8 16>:12") */ [['vib', 'vibmod'], 'vibrato', 'v'], + /** + * Adds pink noise to the mix + * + * @name noise + * @param {number | Pattern} wet wet amount + * @example + * sound("/2") + */ + ['noise'], /** * Sets the vibrato depth in semitones. Only has an effect if `vibrato` | `vib` | `v` is is also set * @@ -1153,7 +1162,7 @@ const generic_params = [ ['pitchJump'], ['pitchJumpTime'], ['lfo', 'repeatTime'], - ['noise'], + ['znoise'], // noise on the frequency or as bubo calls it "frequency fog" :) ['zmod'], ['zcrush'], // like crush but scaled differently ['zdelay'], diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 32ca0bb25..576ec3f19 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -112,3 +112,25 @@ export function createFilter( return filter; } + +// stays 1 until .5, then fades out +let wetfade = (d) => (d < 0.5 ? 1 : 1 - (d - 0.5) / 0.5); + +// mix together dry and wet nodes. 0 = only dry 1 = only wet +// still not too sure about how this could be used more generally... +export function drywet(dry, wet, wetAmount = 0) { + const ac = getAudioContext(); + if (!wetAmount) { + return dry; + } + let dry_gain = ac.createGain(); + let wet_gain = ac.createGain(); + dry.connect(dry_gain); + wet.connect(wet_gain); + dry_gain.gain.value = wetfade(wetAmount); + wet_gain.gain.value = wetfade(1 - wetAmount); + let mix = ac.createGain(); + dry_gain.connect(mix); + wet_gain.connect(mix); + return mix; +} diff --git a/packages/superdough/noise.mjs b/packages/superdough/noise.mjs new file mode 100644 index 000000000..2c8c1d4ae --- /dev/null +++ b/packages/superdough/noise.mjs @@ -0,0 +1,63 @@ +import { drywet } from './helpers.mjs'; +import { getAudioContext } from './superdough.mjs'; + +let noiseCache = {}; + +// lazy generates noise buffers and keeps them forever +function getNoiseBuffer(type) { + const ac = getAudioContext(); + if (noiseCache[type]) { + return noiseCache[type]; + } + const bufferSize = 2 * ac.sampleRate; + const noiseBuffer = ac.createBuffer(1, bufferSize, ac.sampleRate); + const output = noiseBuffer.getChannelData(0); + let lastOut = 0; + let b0, b1, b2, b3, b4, b5, b6; + b0 = b1 = b2 = b3 = b4 = b5 = b6 = 0.0; + + for (let i = 0; i < bufferSize; i++) { + if (type === 'white') { + output[i] = Math.random() * 2 - 1; + } else if (type === 'brown') { + let white = Math.random() * 2 - 1; + output[i] = (lastOut + 0.02 * white) / 1.02; + lastOut = output[i]; + } else if (type === 'pink') { + let white = Math.random() * 2 - 1; + b0 = 0.99886 * b0 + white * 0.0555179; + b1 = 0.99332 * b1 + white * 0.0750759; + b2 = 0.969 * b2 + white * 0.153852; + b3 = 0.8665 * b3 + white * 0.3104856; + b4 = 0.55 * b4 + white * 0.5329522; + b5 = -0.7616 * b5 - white * 0.016898; + output[i] = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362; + output[i] *= 0.11; + b6 = white * 0.115926; + } + } + noiseCache[type] = noiseBuffer; + return noiseBuffer; +} + +// expects one of noises as type +export function getNoiseOscillator(type = 'white', t) { + const ac = getAudioContext(); + const o = ac.createBufferSource(); + o.buffer = getNoiseBuffer(type); + o.loop = true; + o.start(t); + return { + node: o, + stop: (time) => o.stop(time), + }; +} + +export function getNoiseMix(inputNode, wet, t) { + const noiseOscillator = getNoiseOscillator('pink', t); + const noiseMix = drywet(inputNode, noiseOscillator.node, wet); + return { + node: noiseMix, + stop: (time) => noiseOscillator?.stop(time), + }; +} diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index 633d0113d..24d1d5ef7 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -1,6 +1,7 @@ import { midiToFreq, noteToMidi } from './util.mjs'; import { registerSound, getAudioContext } from './superdough.mjs'; import { gainNode, getEnvelope, getExpEnvelope } from './helpers.mjs'; +import { getNoiseMix, getNoiseOscillator } from './noise.mjs'; const mod = (freq, range = 1, type = 'sine') => { const ctx = getAudioContext(); @@ -20,75 +21,26 @@ const fm = (osc, harmonicityRatio, modulationIndex, wave = 'sine') => { return mod(modfreq, modgain, wave); }; +const waveforms = ['sine', 'square', 'triangle', 'sawtooth']; +const noises = ['pink', 'white', 'brown']; + export function registerSynthSounds() { - ['sine', 'square', 'triangle', 'sawtooth'].forEach((wave) => { + [...waveforms, ...noises].forEach((s) => { registerSound( - wave, + s, (t, value, onended) => { // destructure adsr here, because the default should be different for synths and samples - let { - attack = 0.001, - decay = 0.05, - sustain = 0.6, - release = 0.01, - fmh: fmHarmonicity = 1, - fmi: fmModulationIndex, - fmenv: fmEnvelopeType = 'lin', - fmattack: fmAttack, - fmdecay: fmDecay, - fmsustain: fmSustain, - fmrelease: fmRelease, - fmvelocity: fmVelocity, - fmwave: fmWaveform = 'sine', - vib = 0, - vibmod = 0.5, - } = value; - let { n, note, freq } = value; - // with synths, n and note are the same thing - note = note || 36; - if (typeof note === 'string') { - note = noteToMidi(note); // e.g. c3 => 48 - } - // get frequency - if (!freq && typeof note === 'number') { - freq = midiToFreq(note); // + 48); - } - // maybe pull out the above frequency resolution?? (there is also getFrequency but it has no default) - // make oscillator - const { node: o, stop } = getOscillator({ - t, - s: wave, - freq, - vib, - vibmod, - partials: n, - }); - - // FM + FM envelope - let stopFm, fmEnvelope; - if (fmModulationIndex) { - const { node: modulator, stop } = fm(o, fmHarmonicity, fmModulationIndex, fmWaveform); - if (![fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity].find((v) => v !== undefined)) { - // no envelope by default - modulator.connect(o.frequency); - } else { - fmAttack = fmAttack ?? 0.001; - fmDecay = fmDecay ?? 0.001; - fmSustain = fmSustain ?? 1; - fmRelease = fmRelease ?? 0.001; - fmVelocity = fmVelocity ?? 1; - fmEnvelope = getEnvelope(fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity, t); - if (fmEnvelopeType === 'exp') { - fmEnvelope = getExpEnvelope(fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity, t); - fmEnvelope.node.maxValue = fmModulationIndex * 2; - fmEnvelope.node.minValue = 0.00001; - } - modulator.connect(fmEnvelope.node); - fmEnvelope.node.connect(o.frequency); - } - stopFm = stop; + let { attack = 0.001, decay = 0.05, sustain = 0.6, release = 0.01 } = value; + + let sound; + if (waveforms.includes(s)) { + sound = getOscillator(s, t, value); + } else { + sound = getNoiseOscillator(s, t); } + let { node: o, stop, triggerRelease } = sound; + // turn down const g = gainNode(0.3); @@ -104,10 +56,9 @@ export function registerSynthSounds() { node: o.connect(g).connect(envelope), stop: (releaseTime) => { releaseEnvelope(releaseTime); - fmEnvelope?.stop(releaseTime); + triggerRelease?.(releaseTime); let end = releaseTime + release; stop(end); - stopFm?.(end); }, }; }, @@ -146,36 +97,108 @@ export function waveformN(partials, type) { return osc; } -export function getOscillator({ s, freq, t, vib, vibmod, partials }) { - // Make oscillator with partial count +// expects one of waveforms as s +export function getOscillator( + s, + t, + { + n: partials, + note, + freq, + vib = 0, + vibmod = 0.5, + noise = 0, + // fm + fmh: fmHarmonicity = 1, + fmi: fmModulationIndex, + fmenv: fmEnvelopeType = 'lin', + fmattack: fmAttack, + fmdecay: fmDecay, + fmsustain: fmSustain, + fmrelease: fmRelease, + fmvelocity: fmVelocity, + fmwave: fmWaveform = 'sine', + }, +) { + let ac = getAudioContext(); let o; + // If no partials are given, use stock waveforms if (!partials || s === 'sine') { o = getAudioContext().createOscillator(); o.type = s || 'triangle'; - } else { + } + // generate custom waveform if partials are given + else { o = waveformN(partials, s); } + + // get frequency from note... + note = note || 36; + if (typeof note === 'string') { + note = noteToMidi(note); // e.g. c3 => 48 + } + // get frequency + if (!freq && typeof note === 'number') { + freq = midiToFreq(note); // + 48); + } + + // set frequency o.frequency.value = Number(freq); o.start(t); + // FM + let stopFm, fmEnvelope; + if (fmModulationIndex) { + const { node: modulator, stop } = fm(o, fmHarmonicity, fmModulationIndex, fmWaveform); + if (![fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity].find((v) => v !== undefined)) { + // no envelope by default + modulator.connect(o.frequency); + } else { + fmAttack = fmAttack ?? 0.001; + fmDecay = fmDecay ?? 0.001; + fmSustain = fmSustain ?? 1; + fmRelease = fmRelease ?? 0.001; + fmVelocity = fmVelocity ?? 1; + fmEnvelope = getEnvelope(fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity, t); + if (fmEnvelopeType === 'exp') { + fmEnvelope = getExpEnvelope(fmAttack, fmDecay, fmSustain, fmRelease, fmVelocity, t); + fmEnvelope.node.maxValue = fmModulationIndex * 2; + fmEnvelope.node.minValue = 0.00001; + } + modulator.connect(fmEnvelope.node); + fmEnvelope.node.connect(o.frequency); + } + stopFm = stop; + } + // Additional oscillator for vibrato effect - let vibrato_oscillator; + let vibratoOscillator; if (vib > 0) { - vibrato_oscillator = getAudioContext().createOscillator(); - vibrato_oscillator.frequency.value = vib; + vibratoOscillator = getAudioContext().createOscillator(); + vibratoOscillator.frequency.value = vib; const gain = getAudioContext().createGain(); // Vibmod is the amount of vibrato, in semitones gain.gain.value = vibmod * 100; - vibrato_oscillator.connect(gain); + vibratoOscillator.connect(gain); gain.connect(o.detune); - vibrato_oscillator.start(t); + vibratoOscillator.start(t); + } + + let noiseMix; + if (noise) { + noiseMix = getNoiseMix(o, noise, t); } return { - node: o, + node: noiseMix?.node || o, stop: (time) => { - vibrato_oscillator?.stop(time); + vibratoOscillator?.stop(time); + noiseMix?.stop(time); + stopFm?.(time); o.stop(time); }, + triggerRelease: (time) => { + fmEnvelope?.stop(time); + }, }; } diff --git a/packages/superdough/zzfx.mjs b/packages/superdough/zzfx.mjs index da505d747..a6af82609 100644 --- a/packages/superdough/zzfx.mjs +++ b/packages/superdough/zzfx.mjs @@ -20,7 +20,7 @@ export const getZZFX = (value, t) => { pitchJump = 0, pitchJumpTime = 0, lfo = 0, - noise = 0, + znoise = 0, zmod = 0, zcrush = 0, zdelay = 0, @@ -54,7 +54,7 @@ export const getZZFX = (value, t) => { pitchJump, pitchJumpTime, lfo, - noise, + znoise, zmod, zcrush, zdelay, diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index e026f9c43..4a4c966de 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -2959,6 +2959,15 @@ exports[`runs examples > example "never" example index 0 1`] = ` ] `; +exports[`runs examples > example "noise" example index 0 1`] = ` +[ + "[ (0/1 → 1/1) ⇝ 2/1 | s:white ]", + "[ 0/1 ⇜ (1/1 → 2/1) | s:white ]", + "[ (2/1 → 3/1) ⇝ 4/1 | s:pink ]", + "[ 2/1 ⇜ (3/1 → 4/1) | s:pink ]", +] +`; + exports[`runs examples > example "note" example index 0 1`] = ` [ "[ 0/1 → 1/4 | note:c ]", diff --git a/website/src/pages/learn/synths.mdx b/website/src/pages/learn/synths.mdx index 9f21204fa..432276ef4 100644 --- a/website/src/pages/learn/synths.mdx +++ b/website/src/pages/learn/synths.mdx @@ -23,6 +23,25 @@ The basic waveforms are `sine`, `sawtooth`, `square` and `triangle`, which can b If you don't set a `sound` but a `note` the default value for `sound` is `triangle`! +## Noise + +You can also use noise as a source by setting the waveform to: `white`, `pink` or `brown`. These are different +flavours of noise, here written from hard to soft. + +/2").scope()`} /> + +Here's a more musical example of how to use noise for hihats: + +*8") +.decay(.04).sustain(0).scope()`} +/> + +Some amount of pink noise can also be added to any oscillator by using the `noise` paremeter: + +").scope()`} /> + ### Additive Synthesis To tame the harsh sound of the basic waveforms, we can set the `n` control to limit the overtones of the waveform: