UNPKG

superdough

Version:

simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.

566 lines (502 loc) 15.9 kB
import { clamp } from './util.mjs'; import { getSuperdoughAudioController, registerSound, soundMap } from './superdough.mjs'; import { getAudioContext } from './audioContext.mjs'; import { applyFM, gainNode, getADSRValues, getFrequencyFromValue, getLfo, getParamADSR, getPitchEnvelope, getVibratoOscillator, getWorklet, noises, onceEnded, releaseAudioNode, webAudioTimeout, } from './helpers.mjs'; import { logger } from './logger.mjs'; import { getNoiseMix, getNoiseOscillator } from './noise.mjs'; import { getNodeFromPool, releaseNodeToPool } from './nodePools.mjs'; const waveforms = ['triangle', 'square', 'sawtooth', 'sine', 'user', 'one']; const waveformAliases = [ ['tri', 'triangle'], ['sqr', 'square'], ['saw', 'sawtooth'], ['sin', 'sine'], ]; function makeSaturationCurve(amount, n_samples) { const k = typeof amount === 'number' ? amount : 50; const curve = new Float32Array(n_samples); for (let i = 0; i < n_samples; i++) { const x = (i * 2) / n_samples - 1; curve[i] = Math.tanh(x * k); } return curve; } export function registerSynthSounds() { [...waveforms].forEach((s) => { registerSound( s, (t, value, onended) => { const [attack, decay, sustain, release] = getADSRValues( [value.attack, value.decay, value.sustain, value.release], 'linear', [0.001, 0.05, 0.6, 0.01], ); // turn down const g = gainNode(0.3); const sound = getOscillator(s, t, value, () => { releaseAudioNode(g); onended(); }); const { node: o, nodes, stop, triggerRelease } = sound; const { duration } = value; const envGain = gainNode(1); const node = o.connect(g).connect(envGain); const holdEnd = t + duration; getParamADSR(node.gain, attack, decay, sustain, release, 0, 1, t, holdEnd, 'linear'); const envEnd = holdEnd + release + 0.01; triggerRelease?.(envEnd); stop(envEnd); return { node, nodes, stop: (endTime) => { stop(endTime); }, }; }, { type: 'synth', prebake: true }, ); }); registerSound( 'sbd', (t, value, onended) => { const { duration, decay = 0.5, pdecay = 0.5, penv = 36, clip } = value; const ctx = getAudioContext(); const attackhold = 0.02; const noiselvl = 1.2; const noisedecay = 0.025; const mixGain = 1; const o = ctx.createOscillator(); o.type = 'triangle'; o.frequency.value = getFrequencyFromValue(value, 29); o.detune.setValueAtTime(penv * 100, 0); o.detune.setValueAtTime(penv * 100, t); o.detune.exponentialRampToValueAtTime(0.001, t + pdecay); const g = gainNode(1); g.gain.setValueAtTime(1, t + attackhold); g.gain.exponentialRampToValueAtTime(0.001, t + attackhold + decay); o.start(t); const noise = getNoiseOscillator('brown', t, 2); const noiseGain = gainNode(1); noiseGain.gain.setValueAtTime(noiselvl, t); noiseGain.gain.exponentialRampToValueAtTime(0.001, t + noisedecay); const sat = new WaveShaperNode(ctx); // tri to sine diode shaper emulation sat.curve = makeSaturationCurve(2, ctx.sampleRate); const mix = gainNode(mixGain); onceEnded(o, () => { releaseAudioNode(o); releaseAudioNode(g); releaseAudioNode(sat); releaseAudioNode(noise.node); releaseAudioNode(noiseGain); releaseAudioNode(mix); onended(); }); const node = o.connect(sat).connect(g).connect(mix); noise.node.connect(noiseGain).connect(mix); const holdEnd = t + decay; let end = holdEnd + 0.01; if (clip != null) { end = Math.min(t + clip * duration, end); } // prevent clicking mix.gain.setValueAtTime(mixGain, end - 0.01); mix.gain.linearRampToValueAtTime(0, end); o.stop(end); noise.stop(end); return { node, nodes: { source: [o] }, stop: (endTime) => { o.stop(endTime); }, }; }, { type: 'synth', prebake: true }, ); registerSound( 'supersaw', (begin, value, onended) => { const ac = getAudioContext(); let { duration, n, unison = 5, spread = 0.6, detune } = value; detune = detune ?? n ?? 0.18; const frequency = getFrequencyFromValue(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 voices = clamp(unison, 1, 100); let panspread = voices > 1 ? clamp(spread, 0, 1) : 0; const params = { frequency, begin, end, freqspread: detune, voices, panspread, }; const factory = () => new AudioWorkletNode(ac, 'supersaw-oscillator', { outputChannelCount: [2] }); const o = getNodeFromPool('supersaw', factory); Object.entries(params).forEach(([key, value]) => { const param = o.parameters.get(key); const target = value !== undefined ? value : param.defaultValue; param.value = target; }); o.port.postMessage({ type: 'initialize' }); const gainAdjustment = 1 / Math.sqrt(voices); getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend); const vibratoHandle = getVibratoOscillator(o.parameters.get('detune'), value, begin); const fmHandle = applyFM(o.parameters.get('frequency'), value, begin); let envGain = gainNode(1); envGain = o.connect(envGain); getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 0.3 * gainAdjustment, begin, holdend, 'linear'); let timeoutNode = webAudioTimeout( ac, () => { releaseNodeToPool(o); onended(); fmHandle?.stop(); vibratoHandle?.stop(); }, begin, end, ); return { node: envGain, nodes: { source: [o], ...fmHandle?.nodes, ...vibratoHandle?.nodes }, stop: (time) => { timeoutNode.stop(time); }, }; }, { 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', 't*(t >> 10^t)', 't&128', 't&t>>8', '((t%255+t%128+t%64+t%32+t%16+t%127.8+t%64.8+t%32.8+t%16.8)/3)', '((t%64+t%63.8+t%64.15+t%64.35+t%63.5)/1.25)', '(t&(t>>7)-t)', '(sin(t*PI/128)*127+127)', '((t^t/2+t+64*(sin((t*PI/64)+(t*PI/32768))+64))%128*2)', '((t^t/2+t+64*(cos >> 0))%127.85*2)', '((t^t/2+t+64)%128*2)', '(((t * .25)^(t * .25)/100+(t * .25))%128)*2', '((t^t/2+t+64)%7 * 24)', ]; const { n = 0 } = value; const frequency = getFrequencyFromValue(value); const { byteBeatExpression = defaultBeats[n % defaultBeats.length], byteBeatStartTime } = 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; let o = getWorklet( ac, 'byte-beat-processor', { frequency, begin, end, }, { outputChannelCount: [2], }, ); o.port.postMessage({ codeText: byteBeatExpression, byteBeatStartTime, frequency }); let envGain = gainNode(1); envGain = o.connect(envGain); getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear'); let timeoutNode = webAudioTimeout( ac, () => { releaseAudioNode(o); onended(); }, begin, end, ); return { node: envGain, source: o, stop: (time) => { timeoutNode.stop(time); }, }; }, { prebake: true, type: 'synth' }, ); registerSound( 'pulse', (begin, value, onended) => { const ac = getAudioContext(); let { pwrate, pwsweep } = value; if (pwsweep == null) { if (pwrate != null) { pwsweep = 0.3; } else { pwsweep = 0; } } if (pwrate == null && pwsweep != null) { pwrate = 1; } let { duration, pw: pulsewidth = 0.5 } = value; const frequency = getFrequencyFromValue(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; let o = getWorklet( ac, 'pulse-oscillator', { frequency, begin, end, pulsewidth, }, { outputChannelCount: [2], }, ); getPitchEnvelope(o.parameters.get('detune'), value, begin, holdend); const vibratoHandle = getVibratoOscillator(o.parameters.get('detune'), value, begin); const fmHandle = applyFM(o.parameters.get('frequency'), value, begin); let envGain = gainNode(1); envGain = o.connect(envGain); getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear'); let pw_lfo; if (pwsweep != 0) { pw_lfo = getLfo(ac, { frequency: pwrate, depth: pwsweep, begin, end }); pw_lfo.connect(o.parameters.get('pulsewidth')); } let timeoutNode = webAudioTimeout( ac, () => { releaseAudioNode(o); releaseAudioNode(pw_lfo); onended(); fmHandle?.stop(); vibratoHandle?.stop(); }, begin, end, ); return { node: envGain, nodes: { source: [o], pw_lfo: [pw_lfo], ...fmHandle?.nodes, ...vibratoHandle?.nodes }, stop: (time) => { timeoutNode.stop(time); }, }; }, { prebake: true, type: 'synth' }, ); registerSound( 'bus', (begin, value, onended) => { const ac = getAudioContext(); const [attack, decay, sustain, release] = getADSRValues( [value.attack, value.decay, value.sustain, value.release], 'linear', [0.001, 0.05, 1, 0.01], ); const holdend = begin + value.duration; const end = holdend + release + 0.01; const bus = getSuperdoughAudioController().getBus(value.n ?? 0); const envGain = bus.connect(gainNode(0)); getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear'); const timeoutNode = webAudioTimeout( ac, () => { bus.disconnect(envGain); onended(); }, begin, end, ); return { node: envGain, nodes: { source: [bus] }, stop: (time) => { timeoutNode.stop(time); }, }; }, { prebake: true, type: 'input' }, ); [...noises].forEach((s) => { registerSound( s, (t, value, onended) => { const [attack, decay, sustain, release] = getADSRValues( [value.attack, value.decay, value.sustain, value.release], 'linear', [0.001, 0.05, 0.6, 0.01], ); let sound; let { density } = value; sound = getNoiseOscillator(s, t, density); let { node: o, stop, triggerRelease } = sound; // turn down const g = gainNode(0.3); const { duration } = value; onceEnded(o, () => { releaseAudioNode(o); releaseAudioNode(g); onended(); }); const envGain = gainNode(1); let node = o.connect(g).connect(envGain); const holdEnd = t + duration; getParamADSR(node.gain, attack, decay, sustain, release, 0, 1, t, holdEnd, 'linear'); const envEnd = holdEnd + release + 0.01; triggerRelease?.(envEnd); stop(envEnd); return { node, nodes: { source: [o] }, stop: (endTime) => { stop(endTime); }, }; }, { type: 'synth', prebake: true }, ); }); waveformAliases.forEach(([alias, actual]) => soundMap.set({ ...soundMap.get(), [alias]: soundMap.get()[actual] })); } const PI2 = 2 * Math.PI; export function waveformN(partials, phases, type) { const isList = typeof partials === 'object'; partials = isList ? partials : new Float32Array(partials).fill(1); const len = partials.length; const real = new Float32Array(len + 1); const imag = new Float32Array(len + 1); const ac = getAudioContext(); const osc = ac.createOscillator(); const terms = { sawtooth: (n) => [0, -1 / n], square: (n) => [0, n % 2 === 0 ? 0 : 1 / n], triangle: (n) => [n % 2 === 0 ? 0 : 1 / (n * n), 0], user: (_n) => [0, 1], }; if (!terms[type]) { throw new Error(`unknown wave type ${type}`); } for (let n = 0; n < len; n++) { const mag = partials[n]; const [r, i] = terms[type](n + 1); // we skip n === 0 as this is dc offset const phase = phases?.[n] ?? 0; // Scale by `partials` let R = r * mag; let I = i * mag; // Apply rotation by the phase if (phase !== 0) { const c = Math.cos(PI2 * phase); const s = Math.sin(PI2 * phase); R = c * R - s * I; I = s * R + c * I; } real[n + 1] = R; imag[n + 1] = I; } const wave = ac.createPeriodicWave(real, imag); osc.setPeriodicWave(wave); return osc; } // expects one of waveforms as s export function getOscillator(s, t, value, onended) { const { duration, noise = 0 } = value; const partials = value.partials ?? value.n; let o; if (s === 'user' && !partials) { logger( `[superdough] Synth 'user' was selected, but partials not specified. Defaulting to triangle. Use pat.partials to setup custom waveform`, ); s = 'triangle'; } s = s === 'user' && !partials ? 'triangle' : s; if (s === 'one') { // Constant 1 oscillator (used for modulation) o = new ConstantSourceNode(getAudioContext(), { offset: 1 }); o.start(t); return { node: o, nodes: { source: o }, stop: (time) => o?.stop(time), }; } else if (!partials || partials?.length === 0 || s === 'sine') { // If no partials are given, use stock waveforms o = getAudioContext().createOscillator(); o.type = s || 'triangle'; } // generate custom waveform if partials are given else { o = waveformN(partials, value.phases, s); } // set frequency o.frequency.value = getFrequencyFromValue(value); const vibratoHandle = getVibratoOscillator(o.detune, value, t); // pitch envelope getPitchEnvelope(o.detune, value, t, t + duration); const fmHandle = applyFM(o.frequency, value, t); let noiseMix; if (noise) { noiseMix = getNoiseMix(o, noise, t); } onceEnded(o, () => { noiseMix?.teardown(); releaseAudioNode(o); releaseAudioNode(noiseMix?.node); onended(); }); o.start(t); return { node: noiseMix?.node || o, nodes: { source: [o], ...vibratoHandle?.nodes, ...fmHandle?.nodes }, stop: (time) => { fmHandle.stop(time); vibratoHandle?.stop(time); noiseMix?.stop(time); o.stop(time); }, triggerRelease: (time) => { // envGain?.stop(time); }, }; }