From 30fe2dd5026a8e26b8d91c1b77d892f37e340962 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 11 Aug 2023 10:42:59 +0200 Subject: [PATCH 01/13] add superdough package --- packages/superdough/README.md | 35 +++ packages/superdough/example/.gitignore | 24 ++ packages/superdough/example/index.html | 12 + packages/superdough/example/main.js | 25 ++ packages/superdough/example/package.json | 17 ++ packages/superdough/feedbackdelay.mjs | 31 +++ packages/superdough/helpers.mjs | 70 +++++ packages/superdough/index.mjs | 10 + packages/superdough/package.json | 41 +++ packages/superdough/reverb.mjs | 23 ++ packages/superdough/sampler.mjs | 288 +++++++++++++++++++ packages/superdough/superdough.mjs | 249 +++++++++++++++++ packages/superdough/synth.mjs | 44 +++ packages/superdough/util.mjs | 29 ++ packages/superdough/vite.config.js | 19 ++ packages/superdough/vowel.mjs | 38 +++ packages/superdough/worklets.mjs | 108 ++++++++ pnpm-lock.yaml | 337 +++++++++++++++++++++-- pnpm-workspace.yaml | 1 + 19 files changed, 1384 insertions(+), 17 deletions(-) create mode 100644 packages/superdough/README.md create mode 100644 packages/superdough/example/.gitignore create mode 100644 packages/superdough/example/index.html create mode 100644 packages/superdough/example/main.js create mode 100644 packages/superdough/example/package.json create mode 100644 packages/superdough/feedbackdelay.mjs create mode 100644 packages/superdough/helpers.mjs create mode 100644 packages/superdough/index.mjs create mode 100644 packages/superdough/package.json create mode 100644 packages/superdough/reverb.mjs create mode 100644 packages/superdough/sampler.mjs create mode 100644 packages/superdough/superdough.mjs create mode 100644 packages/superdough/synth.mjs create mode 100644 packages/superdough/util.mjs create mode 100644 packages/superdough/vite.config.js create mode 100644 packages/superdough/vowel.mjs create mode 100644 packages/superdough/worklets.mjs diff --git a/packages/superdough/README.md b/packages/superdough/README.md new file mode 100644 index 00000000..7a441841 --- /dev/null +++ b/packages/superdough/README.md @@ -0,0 +1,35 @@ +# superdough + +superdough is a simple web audio sampler and synth, intended for live coding. +It is the default output of [strudel](https://strudel.tidalcycles.org/). +This package has no ties to strudel and can be used to quickly bake your own music system on the web. + +## Install + +via npm: + +```js +npm i superdough --save +``` + +## Use + +```js +import { superdough, samples } from 'superdough'; +// load samples from github +const loadSamples = samples('github:tidalcycles/Dirt-Samples/master'); + +// play some sounds when a button is clicked +document.getElementById('play').addEventListener('click', () => { + superdough({ s: "bd", delay: .5 }, 0); + superdough({ s: "sawtooth", cutoff: 600, resonance: 8 }, 0); + superdough({ s: "hh" }, 0.25); + superdough({ s: "sd", room: .5 }, 0.5); + superdough({ s: "hh" }, 0.75); +}) +``` + +## Credits + +- [SuperDirt](https://github.com/musikinformatik/SuperDirt) +- [WebDirt](https://github.com/dktr0/WebDirt) \ No newline at end of file diff --git a/packages/superdough/example/.gitignore b/packages/superdough/example/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/packages/superdough/example/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/superdough/example/index.html b/packages/superdough/example/index.html new file mode 100644 index 00000000..806cb2f1 --- /dev/null +++ b/packages/superdough/example/index.html @@ -0,0 +1,12 @@ + + + + Superdough Example + + + + + + + + diff --git a/packages/superdough/example/main.js b/packages/superdough/example/main.js new file mode 100644 index 00000000..50861938 --- /dev/null +++ b/packages/superdough/example/main.js @@ -0,0 +1,25 @@ +import { superdough, samples, initAudioOnFirstClick, registerSynthSounds } from 'superdough'; + +initAudioOnFirstClick(); + +const load = Promise.all([samples('github:tidalcycles/Dirt-Samples/master'), registerSynthSounds()]); + +let button = document.getElementById('play'); + +const loop = (t = 0) => { + superdough({ s: 'bd', delay: 0.5 }, t); + superdough({ note: 'g1', s: 'sawtooth', cutoff: 600, resonance: 8 }, t, 0.125); + superdough({ note: 'g2', s: 'sawtooth', cutoff: 600, resonance: 8 }, t + 0.25, 0.125); + superdough({ s: 'hh' }, t + 0.25); + superdough({ s: 'sd', room: 0.5 }, t + 0.5); + superdough({ s: 'hh' }, t + 0.75); +}; + +button.addEventListener('click', async () => { + console.log('play'); + await load; + let t = 0.1; + while (t < 16) { + loop(t++); + } +}); diff --git a/packages/superdough/example/package.json b/packages/superdough/example/package.json new file mode 100644 index 00000000..abe57b82 --- /dev/null +++ b/packages/superdough/example/package.json @@ -0,0 +1,17 @@ +{ + "name": "superdough-example", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "superdough": "workspace:*" + }, + "devDependencies": { + "vite": "^4.4.5" + } +} diff --git a/packages/superdough/feedbackdelay.mjs b/packages/superdough/feedbackdelay.mjs new file mode 100644 index 00000000..c182d655 --- /dev/null +++ b/packages/superdough/feedbackdelay.mjs @@ -0,0 +1,31 @@ +if (typeof DelayNode !== 'undefined') { + class FeedbackDelayNode extends DelayNode { + constructor(ac, wet, time, feedback) { + super(ac); + wet = Math.abs(wet); + this.delayTime.value = time; + + const feedbackGain = ac.createGain(); + feedbackGain.gain.value = Math.min(Math.abs(feedback), 0.995); + this.feedback = feedbackGain.gain; + + const delayGain = ac.createGain(); + delayGain.gain.value = wet; + this.delayGain = delayGain; + + this.connect(feedbackGain); + this.connect(delayGain); + feedbackGain.connect(this); + + this.connect = (target) => delayGain.connect(target); + return this; + } + start(t) { + this.delayGain.gain.setValueAtTime(this.delayGain.gain.value, t + this.delayTime.value); + } + } + + AudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) { + return new FeedbackDelayNode(this, wet, time, feedback); + }; +} diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs new file mode 100644 index 00000000..7cc54c8d --- /dev/null +++ b/packages/superdough/helpers.mjs @@ -0,0 +1,70 @@ +import { getAudioContext } from './superdough.mjs'; + +export function gainNode(value) { + const node = getAudioContext().createGain(); + node.gain.value = value; + return node; +} + +export const getOscillator = ({ s, freq, t }) => { + // make oscillator + const o = getAudioContext().createOscillator(); + o.type = s || 'triangle'; + o.frequency.value = Number(freq); + o.start(t); + //o.stop(t + duration + release); + const stop = (time) => o.stop(time); + return { node: o, stop }; +}; + +// alternative to getADSR returning the gain node and a stop handle to trigger the release anytime in the future +export const getEnvelope = (attack, decay, sustain, release, velocity, begin) => { + const gainNode = getAudioContext().createGain(); + gainNode.gain.setValueAtTime(0, begin); + gainNode.gain.linearRampToValueAtTime(velocity, begin + attack); // attack + gainNode.gain.linearRampToValueAtTime(sustain * velocity, begin + attack + decay); // sustain start + // sustain end + return { + node: gainNode, + stop: (t) => { + //if (typeof gainNode.gain.cancelAndHoldAtTime === 'function') { + // gainNode.gain.cancelAndHoldAtTime(t); // this seems to release instantly.... + // see https://discord.com/channels/779427371270275082/937365093082079272/1086053607360712735 + //} else { + // firefox: this will glitch when the sustain has not been reached yet at the time of release + gainNode.gain.setValueAtTime(sustain * velocity, t); + //} + gainNode.gain.linearRampToValueAtTime(0, t + release); + }, + }; +}; + +export const getADSR = (attack, decay, sustain, release, velocity, begin, end) => { + const gainNode = getAudioContext().createGain(); + gainNode.gain.setValueAtTime(0, begin); + gainNode.gain.linearRampToValueAtTime(velocity, begin + attack); // attack + gainNode.gain.linearRampToValueAtTime(sustain * velocity, begin + attack + decay); // sustain start + gainNode.gain.setValueAtTime(sustain * velocity, end); // sustain end + gainNode.gain.linearRampToValueAtTime(0, end + release); // release + // for some reason, using exponential ramping creates little cracklings + /* let t = begin; + gainNode.gain.setValueAtTime(0, t); + gainNode.gain.exponentialRampToValueAtTime(velocity, (t += attack)); + const sustainGain = Math.max(sustain * velocity, 0.001); + gainNode.gain.exponentialRampToValueAtTime(sustainGain, (t += decay)); + if (end - begin < attack + decay) { + gainNode.gain.cancelAndHoldAtTime(end); + } else { + gainNode.gain.setValueAtTime(sustainGain, end); + } + gainNode.gain.exponentialRampToValueAtTime(0.001, end + release); // release */ + return gainNode; +}; + +export const getFilter = (type, frequency, Q) => { + const filter = getAudioContext().createBiquadFilter(); + filter.type = type; + filter.frequency.value = frequency; + filter.Q.value = Q; + return filter; +}; diff --git a/packages/superdough/index.mjs b/packages/superdough/index.mjs new file mode 100644 index 00000000..9b2067e6 --- /dev/null +++ b/packages/superdough/index.mjs @@ -0,0 +1,10 @@ +/* +index.mjs - +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ + +export * from './superdough.mjs'; +export * from './sampler.mjs'; +export * from './helpers.mjs'; +export * from './synth.mjs'; diff --git a/packages/superdough/package.json b/packages/superdough/package.json new file mode 100644 index 00000000..1065d3f8 --- /dev/null +++ b/packages/superdough/package.json @@ -0,0 +1,41 @@ +{ + "name": "superdough", + "version": "0.9.0", + "description": "simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.", + "main": "index.mjs", + "type": "module", + "publishConfig": { + "main": "dist/index.cjs", + "module": "dist/index.mjs" + }, + "directories": { + "example": "examples" + }, + "scripts": { + "build": "vite build", + "prepublishOnly": "npm run build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/tidalcycles/strudel.git" + }, + "keywords": [ + "tidalcycles", + "strudel", + "pattern", + "livecoding", + "algorave" + ], + "author": "Felix Roos ", + "license": "AGPL-3.0-or-later", + "bugs": { + "url": "https://github.com/tidalcycles/strudel/issues" + }, + "homepage": "https://github.com/tidalcycles/strudel#readme", + "devDependencies": { + "vite": "^4.3.3" + }, + "dependencies": { + "nanostores": "^0.8.1" + } +} diff --git a/packages/superdough/reverb.mjs b/packages/superdough/reverb.mjs new file mode 100644 index 00000000..e6d31f6a --- /dev/null +++ b/packages/superdough/reverb.mjs @@ -0,0 +1,23 @@ +if (typeof AudioContext !== 'undefined') { + AudioContext.prototype.impulseResponse = function (duration, channels = 1) { + const length = this.sampleRate * duration; + const impulse = this.createBuffer(channels, length, this.sampleRate); + const IR = impulse.getChannelData(0); + for (let i = 0; i < length; i++) IR[i] = (2 * Math.random() - 1) * Math.pow(1 - i / length, duration); + return impulse; + }; + + AudioContext.prototype.createReverb = function (duration) { + const convolver = this.createConvolver(); + convolver.setDuration = (d) => { + convolver.buffer = this.impulseResponse(d); + convolver.duration = duration; + return convolver; + }; + convolver.setDuration(duration); + return convolver; + }; +} + +// TODO: make the reverb more exciting +// check out https://blog.gskinner.com/archives/2019/02/reverb-web-audio-api.html diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs new file mode 100644 index 00000000..17275a99 --- /dev/null +++ b/packages/superdough/sampler.mjs @@ -0,0 +1,288 @@ +import { logger, noteToMidi, valueToMidi } from '@strudel.cycles/core'; +import { getAudioContext, registerSound } from './index.mjs'; +import { getEnvelope } from './helpers.mjs'; + +const bufferCache = {}; // string: Promise +const loadCache = {}; // string: Promise + +export const getCachedBuffer = (url) => bufferCache[url]; + +function humanFileSize(bytes, si) { + var thresh = si ? 1000 : 1024; + if (bytes < thresh) return bytes + ' B'; + var units = si + ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + var u = -1; + do { + bytes /= thresh; + ++u; + } while (bytes >= thresh); + return bytes.toFixed(1) + ' ' + units[u]; +} + +export const getSampleBufferSource = async (s, n, note, speed, freq, bank, resolveUrl) => { + let transpose = 0; + if (freq !== undefined && note !== undefined) { + logger('[sampler] hap has note and freq. ignoring note', 'warning'); + } + let midi = valueToMidi({ freq, note }, 36); + transpose = midi - 36; // C3 is middle C + + const ac = getAudioContext(); + let sampleUrl; + if (Array.isArray(bank)) { + sampleUrl = bank[n % bank.length]; + } else { + const midiDiff = (noteA) => noteToMidi(noteA) - midi; + // object format will expect keys as notes + const closest = Object.keys(bank) + .filter((k) => !k.startsWith('_')) + .reduce( + (closest, key, j) => (!closest || Math.abs(midiDiff(key)) < Math.abs(midiDiff(closest)) ? key : closest), + null, + ); + transpose = -midiDiff(closest); // semitones to repitch + sampleUrl = bank[closest][n % bank[closest].length]; + } + if (resolveUrl) { + sampleUrl = await resolveUrl(sampleUrl); + } + let buffer = await loadBuffer(sampleUrl, ac, s, n); + if (speed < 0) { + // should this be cached? + buffer = reverseBuffer(buffer); + } + const bufferSource = ac.createBufferSource(); + bufferSource.buffer = buffer; + const playbackRate = 1.0 * Math.pow(2, transpose / 12); + // bufferSource.playbackRate.value = Math.pow(2, transpose / 12); + bufferSource.playbackRate.value = playbackRate; + return bufferSource; +}; + +export const loadBuffer = (url, ac, s, n = 0) => { + const label = s ? `sound "${s}:${n}"` : 'sample'; + if (!loadCache[url]) { + logger(`[sampler] load ${label}..`, 'load-sample', { url }); + const timestamp = Date.now(); + loadCache[url] = fetch(url) + .then((res) => res.arrayBuffer()) + .then(async (res) => { + const took = Date.now() - timestamp; + const size = humanFileSize(res.byteLength); + // const downSpeed = humanFileSize(res.byteLength / took); + logger(`[sampler] load ${label}... done! loaded ${size} in ${took}ms`, 'loaded-sample', { url }); + const decoded = await ac.decodeAudioData(res); + bufferCache[url] = decoded; + return decoded; + }); + } + return loadCache[url]; +}; + +export function reverseBuffer(buffer) { + const ac = getAudioContext(); + const reversed = ac.createBuffer(buffer.numberOfChannels, buffer.length, ac.sampleRate); + for (let channel = 0; channel < buffer.numberOfChannels; channel++) { + reversed.copyToChannel(buffer.getChannelData(channel).slice().reverse(), channel, channel); + } + return reversed; +} + +export const getLoadedBuffer = (url) => { + return bufferCache[url]; +}; + +export const processSampleMap = (sampleMap, fn, baseUrl = sampleMap._base || '') => { + return Object.entries(sampleMap).forEach(([key, value]) => { + if (typeof value === 'string') { + value = [value]; + } + if (typeof value !== 'object') { + throw new Error('wrong sample map format for ' + key); + } + baseUrl = value._base || baseUrl; + const replaceUrl = (v) => (baseUrl + v).replace('github:', 'https://raw.githubusercontent.com/'); + if (Array.isArray(value)) { + //return [key, value.map(replaceUrl)]; + value = value.map(replaceUrl); + } else { + // must be object + value = Object.fromEntries( + Object.entries(value).map(([note, samples]) => { + return [note, (typeof samples === 'string' ? [samples] : samples).map(replaceUrl)]; + }), + ); + } + fn(key, value); + }); +}; + +// allows adding a custom url prefix handler +// for example, it is used by the desktop app to load samples starting with '~/music' +let resourcePrefixHandlers = {}; +export function registerSamplesPrefix(prefix, resolve) { + resourcePrefixHandlers[prefix] = resolve; +} +// finds a prefix handler for the given url (if any) +function getSamplesPrefixHandler(url) { + const handler = Object.entries(resourcePrefixHandlers).find(([key]) => url.startsWith(key)); + if (handler) { + return handler[1]; + } + return; +} + +/** + * Loads a collection of samples to use with `s` + * @example + * samples('github:tidalcycles/Dirt-Samples/master'); + * s("[bd ~]*2, [~ hh]*2, ~ sd") + * @example + * samples({ + * bd: '808bd/BD0000.WAV', + * sd: '808sd/SD0010.WAV' + * }, 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/'); + * s("[bd ~]*2, [~ hh]*2, ~ sd") + * + */ + +export const samples = async (sampleMap, baseUrl = sampleMap._base || '', options = {}) => { + if (typeof sampleMap === 'string') { + // check if custom prefix handler + const handler = getSamplesPrefixHandler(sampleMap); + if (handler) { + return handler(sampleMap); + } + if (sampleMap.startsWith('github:')) { + let [_, path] = sampleMap.split('github:'); + path = path.endsWith('/') ? path.slice(0, -1) : path; + sampleMap = `https://raw.githubusercontent.com/${path}/strudel.json`; + } + if (typeof fetch !== 'function') { + // not a browser + return; + } + const base = sampleMap.split('/').slice(0, -1).join('/'); + if (typeof fetch === 'undefined') { + // skip fetch when in node / testing + return; + } + return fetch(sampleMap) + .then((res) => res.json()) + .then((json) => samples(json, baseUrl || json._base || base, options)) + .catch((error) => { + console.error(error); + throw new Error(`error loading "${sampleMap}"`); + }); + } + const { prebake, tag } = options; + processSampleMap( + sampleMap, + (key, value) => + registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, value), { + type: 'sample', + samples: value, + baseUrl, + prebake, + tag, + }), + baseUrl, + ); +}; + +const cutGroups = []; + +export async function onTriggerSample(t, value, onended, bank, resolveUrl) { + const { + s, + freq, + unit, + nudge = 0, // TODO: is this in seconds? + cut, + loop, + clip = undefined, // if 1, samples will be cut off when the hap ends + n = 0, + note, + speed = 1, // sample playback speed + begin = 0, + end = 1, + } = value; + // load sample + if (speed === 0) { + // no playback + return; + } + const ac = getAudioContext(); + // destructure adsr here, because the default should be different for synths and samples + const { attack = 0.001, decay = 0.001, sustain = 1, release = 0.001 } = value; + //const soundfont = getSoundfontKey(s); + const time = t + nudge; + + const bufferSource = await getSampleBufferSource(s, n, note, speed, freq, bank, resolveUrl); + + // asny stuff above took too long? + if (ac.currentTime > t) { + logger(`[sampler] still loading sound "${s}:${n}"`, 'highlight'); + // console.warn('sample still loading:', s, n); + return; + } + if (!bufferSource) { + logger(`[sampler] could not load "${s}:${n}"`, 'error'); + return; + } + bufferSource.playbackRate.value = Math.abs(speed) * bufferSource.playbackRate.value; + if (unit === 'c') { + // are there other units? + bufferSource.playbackRate.value = bufferSource.playbackRate.value * bufferSource.buffer.duration * 1; //cps; + } + // "The computation of the offset into the sound is performed using the sound buffer's natural sample rate, + // rather than the current playback rate, so even if the sound is playing at twice its normal speed, + // the midway point through a 10-second audio buffer is still 5." + const offset = begin * bufferSource.buffer.duration; + bufferSource.start(time, offset); + const bufferDuration = bufferSource.buffer.duration / bufferSource.playbackRate.value; + /*if (loop) { + // TODO: idea for loopBegin / loopEnd + // if one of [loopBegin,loopEnd] is <= 1, interpret it as normlized + // if [loopBegin,loopEnd] is bigger >= 1, interpret it as sample number + // this will simplify perfectly looping things, while still keeping the normalized option + // the only drawback is that looping between samples 0 and 1 is not possible (which is not real use case) + bufferSource.loop = true; + bufferSource.loopStart = offset; + bufferSource.loopEnd = offset + duration; + duration = loop * duration; + }*/ + const { node: envelope, stop: releaseEnvelope } = getEnvelope(attack, decay, sustain, release, 1, t); + bufferSource.connect(envelope); + const out = ac.createGain(); // we need a separate gain for the cutgroups because firefox... + envelope.connect(out); + bufferSource.onended = function () { + bufferSource.disconnect(); + envelope.disconnect(); + out.disconnect(); + onended(); + }; + const stop = (endTime, playWholeBuffer = clip === undefined) => { + let releaseTime = endTime; + if (playWholeBuffer) { + releaseTime = t + (end - begin) * bufferDuration; + } + bufferSource.stop(releaseTime + release); + releaseEnvelope(releaseTime); + }; + const handle = { node: out, bufferSource, stop }; + + // cut groups + if (cut !== undefined) { + const prev = cutGroups[cut]; + if (prev) { + prev.node.gain.setValueAtTime(1, time); + prev.node.gain.linearRampToValueAtTime(0, time + 0.01); + } + cutGroups[cut] = handle; + } + + return handle; +} diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs new file mode 100644 index 00000000..a413ee9a --- /dev/null +++ b/packages/superdough/superdough.mjs @@ -0,0 +1,249 @@ +/* +superdough.mjs - +Copyright (C) 2022 Strudel contributors - see +This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . +*/ + +import './feedbackdelay.mjs'; +import './reverb.mjs'; +import './vowel.mjs'; +import { clamp } from './util.mjs'; +import workletsUrl from './worklets.mjs?url'; +import { getFilter, gainNode } from './helpers.mjs'; +import { map } from 'nanostores'; + +export const soundMap = map(); +export function registerSound(key, onTrigger, data = {}) { + soundMap.setKey(key, { onTrigger, data }); +} +export function getSound(s) { + return soundMap.get()[s]; +} +export const resetLoadedSounds = () => soundMap.set({}); + +let audioContext; +export const getAudioContext = () => { + if (!audioContext) { + audioContext = new AudioContext(); + } + return audioContext; +}; + +let destination; +const getDestination = () => { + const ctx = getAudioContext(); + if (!destination) { + destination = ctx.createGain(); + destination.connect(ctx.destination); + } + return destination; +}; + +export const panic = () => { + getDestination().gain.linearRampToValueAtTime(0, getAudioContext().currentTime + 0.01); + destination = null; +}; + +let workletsLoading; +function loadWorklets() { + if (workletsLoading) { + return workletsLoading; + } + workletsLoading = getAudioContext().audioWorklet.addModule(workletsUrl); + return workletsLoading; +} + +function getWorklet(ac, processor, params) { + const node = new AudioWorkletNode(ac, processor); + Object.entries(params).forEach(([key, value]) => { + node.parameters.get(key).value = value; + }); + return node; +} + +// this function should be called on first user interaction (to avoid console warning) +export async function initAudio() { + if (typeof window !== 'undefined') { + try { + await getAudioContext().resume(); + await loadWorklets(); + } catch (err) { + console.warn('could not load AudioWorklet effects coarse, crush and shape', err); + } + } +} + +export async function initAudioOnFirstClick() { + return new Promise((resolve) => { + document.addEventListener('click', async function listener() { + await initAudio(); + resolve(); + document.removeEventListener('click', listener); + }); + }); +} + +let delays = {}; +const maxfeedback = 0.98; +function getDelay(orbit, delaytime, delayfeedback, t) { + if (delayfeedback > maxfeedback) { + //logger(`delayfeedback was clamped to ${maxfeedback} to save your ears`); + } + delayfeedback = clamp(delayfeedback, 0, 0.98); + if (!delays[orbit]) { + const ac = getAudioContext(); + const dly = ac.createFeedbackDelay(1, delaytime, delayfeedback); + dly.start?.(t); // for some reason, this throws when audion extension is installed.. + dly.connect(getDestination()); + delays[orbit] = dly; + } + delays[orbit].delayTime.value !== delaytime && delays[orbit].delayTime.setValueAtTime(delaytime, t); + delays[orbit].feedback.value !== delayfeedback && delays[orbit].feedback.setValueAtTime(delayfeedback, t); + return delays[orbit]; +} + +let reverbs = {}; +function getReverb(orbit, duration = 2) { + if (!reverbs[orbit]) { + const ac = getAudioContext(); + const reverb = ac.createReverb(duration); + reverb.connect(getDestination()); + reverbs[orbit] = reverb; + } + if (reverbs[orbit].duration !== duration) { + reverbs[orbit] = reverbs[orbit].setDuration(duration); + reverbs[orbit].duration = duration; + } + return reverbs[orbit]; +} + +function effectSend(input, effect, wet) { + const send = gainNode(wet); + input.connect(send); + send.connect(effect); + return send; +} + +export const superdough = async (value, deadline, hapDuration, cps) => { + const ac = getAudioContext(); + if (typeof value !== 'object') { + throw new Error( + `expected hap.value to be an object, but got "${value}". Hint: append .note() or .s() to the end`, + 'error', + ); + } + + // calculate absolute time + let t = ac.currentTime + deadline; + // destructure + let { + s = 'triangle', + bank, + source, + gain = 0.8, + // low pass + cutoff, + resonance = 1, + // high pass + hcutoff, + hresonance = 1, + // band pass + bandf, + bandq = 1, + // + coarse, + crush, + shape, + pan, + vowel, + delay = 0, + delayfeedback = 0.5, + delaytime = 0.25, + orbit = 1, + room, + size = 2, + velocity = 1, + } = value; + // const { velocity = 1 } = hap.context; + gain *= velocity; // legacy fix for velocity + let toDisconnect = []; // audio nodes that will be disconnected when the source has ended + const onended = () => { + toDisconnect.forEach((n) => n?.disconnect()); + }; + if (bank && s) { + s = `${bank}_${s}`; + } + // get source AudioNode + let sourceNode; + if (source) { + sourceNode = source(t, value, hapDuration); + } else if (getSound(s)) { + const { onTrigger } = getSound(s); + const soundHandle = await onTrigger(t, value, onended); + if (soundHandle) { + sourceNode = soundHandle.node; + soundHandle.stop(t + hapDuration); + } + } else { + throw new Error(`sound ${s} not found! Is it loaded?`); + } + if (!sourceNode) { + // if onTrigger does not return anything, we will just silently skip + // this can be used for things like speed(0) in the sampler + return; + } + if (ac.currentTime > t) { + // logger('[webaudio] skip hap: still loading', ac.currentTime - t); + return; + } + const chain = []; // audio nodes that will be connected to each other sequentially + chain.push(sourceNode); + + // gain stage + chain.push(gainNode(gain)); + + // filters + cutoff !== undefined && chain.push(getFilter('lowpass', cutoff, resonance)); + hcutoff !== undefined && chain.push(getFilter('highpass', hcutoff, hresonance)); + bandf !== undefined && chain.push(getFilter('bandpass', bandf, bandq)); + vowel !== undefined && chain.push(ac.createVowelFilter(vowel)); + + // effects + coarse !== undefined && chain.push(getWorklet(ac, 'coarse-processor', { coarse })); + crush !== undefined && chain.push(getWorklet(ac, 'crush-processor', { crush })); + shape !== undefined && chain.push(getWorklet(ac, 'shape-processor', { shape })); + + // panning + if (pan !== undefined) { + const panner = ac.createStereoPanner(); + panner.pan.value = 2 * pan - 1; + chain.push(panner); + } + + // last gain + const post = gainNode(1); + chain.push(post); + post.connect(getDestination()); + + // delay + let delaySend; + if (delay > 0 && delaytime > 0 && delayfeedback > 0) { + const delyNode = getDelay(orbit, delaytime, delayfeedback, t); + delaySend = effectSend(post, delyNode, delay); + } + // reverb + let reverbSend; + if (room > 0 && size > 0) { + const reverbNode = getReverb(orbit, size); + reverbSend = effectSend(post, reverbNode, room); + } + + // connect chain elements together + chain.slice(1).reduce((last, current) => last.connect(current), chain[0]); + + // toDisconnect = all the node that should be disconnected in onended callback + // this is crucial for performance + toDisconnect = chain.concat([delaySend, reverbSend]); +}; + +export const superdoughTrigger = (t, hap, ct, cps) => superdough(hap, t - ct, hap.duration / cps, cps); diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs new file mode 100644 index 00000000..73174f65 --- /dev/null +++ b/packages/superdough/synth.mjs @@ -0,0 +1,44 @@ +import { midiToFreq, noteToMidi } from './util.mjs'; +import { registerSound } from './superdough.mjs'; +import { getOscillator, gainNode, getEnvelope } from './helpers.mjs'; + +export function registerSynthSounds() { + ['sine', 'square', 'triangle', 'sawtooth'].forEach((wave) => { + registerSound( + wave, + (t, value, onended) => { + // destructure adsr here, because the default should be different for synths and samples + const { attack = 0.001, decay = 0.05, sustain = 0.6, release = 0.01 } = value; + let { n, note, freq } = value; + // with synths, n and note are the same thing + n = note || n || 36; + if (typeof n === 'string') { + n = noteToMidi(n); // e.g. c3 => 48 + } + // get frequency + if (!freq && typeof n === 'number') { + freq = midiToFreq(n); // + 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 }); + const g = gainNode(0.3); + // envelope + const { node: envelope, stop: releaseEnvelope } = getEnvelope(attack, decay, sustain, release, 1, t); + o.onended = () => { + o.disconnect(); + g.disconnect(); + onended(); + }; + return { + node: o.connect(g).connect(envelope), + stop: (releaseTime) => { + releaseEnvelope(releaseTime); + stop(releaseTime + release); + }, + }; + }, + { type: 'synth', prebake: true }, + ); + }); +} diff --git a/packages/superdough/util.mjs b/packages/superdough/util.mjs new file mode 100644 index 00000000..860aa9c1 --- /dev/null +++ b/packages/superdough/util.mjs @@ -0,0 +1,29 @@ +// currently duplicate with core util.mjs to skip dependency +// TODO: add separate util module? + +export const tokenizeNote = (note) => { + if (typeof note !== 'string') { + return []; + } + const [pc, acc = '', oct] = note.match(/^([a-gA-G])([#bsf]*)([0-9]*)$/)?.slice(1) || []; + if (!pc) { + return []; + } + return [pc, acc, oct ? Number(oct) : undefined]; +}; +const chromas = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; +const accs = { '#': 1, b: -1, s: 1, f: -1 }; + +export const noteToMidi = (note, defaultOctave = 3) => { + const [pc, acc, oct = defaultOctave] = tokenizeNote(note); + if (!pc) { + throw new Error('not a note: "' + note + '"'); + } + const chroma = chromas[pc.toLowerCase()]; + const offset = acc?.split('').reduce((o, char) => o + accs[char], 0) || 0; + return (Number(oct) + 1) * 12 + chroma + offset; +}; +export const midiToFreq = (n) => { + return Math.pow(2, (n - 69) / 12) * 440; +}; +export const clamp = (num, min, max) => Math.min(Math.max(num, min), max); diff --git a/packages/superdough/vite.config.js b/packages/superdough/vite.config.js new file mode 100644 index 00000000..ccf68b35 --- /dev/null +++ b/packages/superdough/vite.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +import { dependencies } from './package.json'; +import { resolve } from 'path'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [], + build: { + lib: { + entry: resolve(__dirname, 'superdough.mjs'), + formats: ['es', 'cjs'], + fileName: (ext) => ({ es: 'superdough.mjs', cjs: 'superdough.cjs' }[ext]), + }, + rollupOptions: { + external: [...Object.keys(dependencies)], + }, + target: 'esnext', + }, +}); diff --git a/packages/superdough/vowel.mjs b/packages/superdough/vowel.mjs new file mode 100644 index 00000000..25357642 --- /dev/null +++ b/packages/superdough/vowel.mjs @@ -0,0 +1,38 @@ +// credits to webdirt: https://github.com/dktr0/WebDirt/blob/41342e81d6ad694a2310d491fef7b7e8b0929efe/js-src/Graph.js#L597 +export var vowelFormant = { + a: { freqs: [660, 1120, 2750, 3000, 3350], gains: [1, 0.5012, 0.0708, 0.0631, 0.0126], qs: [80, 90, 120, 130, 140] }, + e: { freqs: [440, 1800, 2700, 3000, 3300], gains: [1, 0.1995, 0.1259, 0.1, 0.1], qs: [70, 80, 100, 120, 120] }, + i: { freqs: [270, 1850, 2900, 3350, 3590], gains: [1, 0.0631, 0.0631, 0.0158, 0.0158], qs: [40, 90, 100, 120, 120] }, + o: { freqs: [430, 820, 2700, 3000, 3300], gains: [1, 0.3162, 0.0501, 0.0794, 0.01995], qs: [40, 80, 100, 120, 120] }, + u: { freqs: [370, 630, 2750, 3000, 3400], gains: [1, 0.1, 0.0708, 0.0316, 0.01995], qs: [40, 60, 100, 120, 120] }, +}; +if (typeof GainNode !== 'undefined') { + class VowelNode extends GainNode { + constructor(ac, letter) { + super(ac); + if (!vowelFormant[letter]) { + throw new Error('vowel: unknown vowel ' + letter); + } + const { gains, qs, freqs } = vowelFormant[letter]; + const makeupGain = ac.createGain(); + for (let i = 0; i < 5; i++) { + const gain = ac.createGain(); + gain.gain.value = gains[i]; + const filter = ac.createBiquadFilter(); + filter.type = 'bandpass'; + filter.Q.value = qs[i]; + filter.frequency.value = freqs[i]; + this.connect(filter); + filter.connect(gain); + gain.connect(makeupGain); + } + makeupGain.gain.value = 8; // how much makeup gain to add? + this.connect = (target) => makeupGain.connect(target); + return this; + } + } + + AudioContext.prototype.createVowelFilter = function (letter) { + return new VowelNode(this, letter); + }; +} diff --git a/packages/superdough/worklets.mjs b/packages/superdough/worklets.mjs new file mode 100644 index 00000000..7bb43f87 --- /dev/null +++ b/packages/superdough/worklets.mjs @@ -0,0 +1,108 @@ +// LICENSE GNU General Public License v3.0 see https://github.com/dktr0/WebDirt/blob/main/LICENSE +// all the credit goes to dktr0's webdirt: https://github.com/dktr0/WebDirt/blob/5ce3d698362c54d6e1b68acc47eb2955ac62c793/dist/AudioWorklets.js +// <3 + +class CoarseProcessor extends AudioWorkletProcessor { + static get parameterDescriptors() { + return [{ name: 'coarse', defaultValue: 1 }]; + } + + constructor() { + super(); + this.notStarted = true; + } + + process(inputs, outputs, parameters) { + const input = inputs[0]; + const output = outputs[0]; + const coarse = parameters.coarse; + const blockSize = 128; + const hasInput = !(input[0] === undefined); + if (hasInput) { + this.notStarted = false; + output[0][0] = input[0][0]; + for (let n = 1; n < blockSize; n++) { + for (let o = 0; o < output.length; o++) { + output[o][n] = n % coarse == 0 ? input[0][n] : output[o][n - 1]; + } + } + } + return this.notStarted || hasInput; + } +} + +registerProcessor('coarse-processor', CoarseProcessor); + +class CrushProcessor extends AudioWorkletProcessor { + static get parameterDescriptors() { + return [{ name: 'crush', defaultValue: 0 }]; + } + + constructor() { + super(); + this.notStarted = true; + } + + process(inputs, outputs, parameters) { + const input = inputs[0]; + const output = outputs[0]; + const crush = parameters.crush; + const blockSize = 128; + const hasInput = !(input[0] === undefined); + if (hasInput) { + this.notStarted = false; + if (crush.length === 1) { + const x = Math.pow(2, crush[0] - 1); + for (let n = 0; n < blockSize; n++) { + const value = Math.round(input[0][n] * x) / x; + for (let o = 0; o < output.length; o++) { + output[o][n] = value; + } + } + } else { + for (let n = 0; n < blockSize; n++) { + let x = Math.pow(2, crush[n] - 1); + const value = Math.round(input[0][n] * x) / x; + for (let o = 0; o < output.length; o++) { + output[o][n] = value; + } + } + } + } + return this.notStarted || hasInput; + } +} +registerProcessor('crush-processor', CrushProcessor); + +class ShapeProcessor extends AudioWorkletProcessor { + static get parameterDescriptors() { + return [{ name: 'shape', defaultValue: 0 }]; + } + + constructor() { + super(); + this.notStarted = true; + } + + process(inputs, outputs, parameters) { + const input = inputs[0]; + const output = outputs[0]; + const shape0 = parameters.shape[0]; + const shape1 = shape0 < 1 ? shape0 : 1.0 - 4e-10; + const shape = (2.0 * shape1) / (1.0 - shape1); + const blockSize = 128; + const hasInput = !(input[0] === undefined); + if (hasInput) { + this.notStarted = false; + for (let n = 0; n < blockSize; n++) { + const value = ((1 + shape) * input[0][n]) / (1 + shape * Math.abs(input[0][n])); + for (let o = 0; o < output.length; o++) { + output[o][n] = value; + } + } + } + return this.notStarted || hasInput; + } +} + +registerProcessor('shape-processor', ShapeProcessor); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a1cd78d..86d83437 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -386,6 +386,26 @@ importers: specifier: ^4.3.3 version: 4.3.3(@types/node@18.16.3) + packages/superdough: + dependencies: + nanostores: + specifier: ^0.8.1 + version: 0.8.1 + devDependencies: + vite: + specifier: ^4.3.3 + version: 4.3.3(@types/node@18.16.3) + + packages/superdough/example: + dependencies: + superdough: + specifier: workspace:* + version: link:.. + devDependencies: + vite: + specifier: ^4.4.5 + version: 4.4.5(@types/node@18.16.3) + packages/tonal: dependencies: '@strudel.cycles/core': @@ -498,7 +518,7 @@ importers: version: 4.17.0 '@astrojs/mdx': specifier: ^0.19.0 - version: 0.19.0(astro@2.3.2)(rollup@3.21.0) + version: 0.19.0(astro@2.3.2)(rollup@3.28.0) '@astrojs/react': specifier: ^2.1.1 version: 2.1.1(@types/react-dom@18.2.1)(@types/react@18.2.0)(react-dom@18.2.0)(react@18.2.0) @@ -628,7 +648,7 @@ importers: version: 3.0.3 vite-plugin-pwa: specifier: ^0.14.7 - version: 0.14.7(vite@4.3.3)(workbox-build@6.5.4)(workbox-window@6.5.4) + version: 0.14.7(vite@4.4.5)(workbox-build@6.5.4)(workbox-window@6.5.4) workbox-window: specifier: ^6.5.4 version: 6.5.4 @@ -849,14 +869,14 @@ packages: transitivePeerDependencies: - supports-color - /@astrojs/mdx@0.19.0(astro@2.3.2)(rollup@3.21.0): + /@astrojs/mdx@0.19.0(astro@2.3.2)(rollup@3.28.0): resolution: {integrity: sha512-McFpMV+npinIEKnY5t9hsdzLd76g78GgIRUPxem2OeXPNB8xr2pNS28GeU0+6Pn5STnB+sgcyyeqXLgzauOlMQ==} engines: {node: '>=16.12.0'} dependencies: '@astrojs/markdown-remark': 2.1.4(astro@2.3.2) '@astrojs/prism': 2.1.1 '@mdx-js/mdx': 2.3.0 - '@mdx-js/rollup': 2.3.0(rollup@3.21.0) + '@mdx-js/rollup': 2.3.0(rollup@3.28.0) acorn: 8.8.2 es-module-lexer: 1.2.1 estree-util-visit: 1.2.1 @@ -2438,6 +2458,15 @@ packages: cpu: [arm64] os: [android] requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm64@0.18.20: + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true optional: true /@esbuild/android-arm@0.17.18: @@ -2446,6 +2475,15 @@ packages: cpu: [arm] os: [android] requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm@0.18.20: + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true optional: true /@esbuild/android-x64@0.17.18: @@ -2454,6 +2492,15 @@ packages: cpu: [x64] os: [android] requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64@0.18.20: + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true optional: true /@esbuild/darwin-arm64@0.17.18: @@ -2462,6 +2509,15 @@ packages: cpu: [arm64] os: [darwin] requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-arm64@0.18.20: + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true optional: true /@esbuild/darwin-x64@0.17.18: @@ -2470,6 +2526,15 @@ packages: cpu: [x64] os: [darwin] requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-x64@0.18.20: + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true optional: true /@esbuild/freebsd-arm64@0.17.18: @@ -2478,6 +2543,15 @@ packages: cpu: [arm64] os: [freebsd] requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-arm64@0.18.20: + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true optional: true /@esbuild/freebsd-x64@0.17.18: @@ -2486,6 +2560,15 @@ packages: cpu: [x64] os: [freebsd] requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-x64@0.18.20: + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true optional: true /@esbuild/linux-arm64@0.17.18: @@ -2494,6 +2577,15 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm64@0.18.20: + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-arm@0.17.18: @@ -2502,6 +2594,15 @@ packages: cpu: [arm] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm@0.18.20: + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-ia32@0.17.18: @@ -2510,6 +2611,15 @@ packages: cpu: [ia32] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.18.20: + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-loong64@0.17.18: @@ -2518,6 +2628,15 @@ packages: cpu: [loong64] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.18.20: + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-mips64el@0.17.18: @@ -2526,6 +2645,15 @@ packages: cpu: [mips64el] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el@0.18.20: + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-ppc64@0.17.18: @@ -2534,6 +2662,15 @@ packages: cpu: [ppc64] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64@0.18.20: + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-riscv64@0.17.18: @@ -2542,6 +2679,15 @@ packages: cpu: [riscv64] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64@0.18.20: + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-s390x@0.17.18: @@ -2550,6 +2696,15 @@ packages: cpu: [s390x] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x@0.18.20: + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true optional: true /@esbuild/linux-x64@0.17.18: @@ -2558,6 +2713,15 @@ packages: cpu: [x64] os: [linux] requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64@0.18.20: + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true optional: true /@esbuild/netbsd-x64@0.17.18: @@ -2566,6 +2730,15 @@ packages: cpu: [x64] os: [netbsd] requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64@0.18.20: + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true optional: true /@esbuild/openbsd-x64@0.17.18: @@ -2574,6 +2747,15 @@ packages: cpu: [x64] os: [openbsd] requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64@0.18.20: + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true optional: true /@esbuild/sunos-x64@0.17.18: @@ -2582,6 +2764,15 @@ packages: cpu: [x64] os: [sunos] requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64@0.18.20: + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true optional: true /@esbuild/win32-arm64@0.17.18: @@ -2590,6 +2781,15 @@ packages: cpu: [arm64] os: [win32] requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64@0.18.20: + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true optional: true /@esbuild/win32-ia32@0.17.18: @@ -2598,6 +2798,15 @@ packages: cpu: [ia32] os: [win32] requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32@0.18.20: + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true optional: true /@esbuild/win32-x64@0.17.18: @@ -2606,6 +2815,15 @@ packages: cpu: [x64] os: [win32] requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64@0.18.20: + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true optional: true /@eslint-community/eslint-utils@4.4.0(eslint@8.39.0): @@ -2927,14 +3145,14 @@ packages: - supports-color dev: false - /@mdx-js/rollup@2.3.0(rollup@3.21.0): + /@mdx-js/rollup@2.3.0(rollup@3.28.0): resolution: {integrity: sha512-wLvRfJS/M4UmdqTd+WoaySEE7q4BIejYf1xAHXYvtT1du/1Tl/z2450Gg2+Hu7fh05KwRRiehiTP9Yc/Dtn0fA==} peerDependencies: rollup: '>=2' dependencies: '@mdx-js/mdx': 2.3.0 - '@rollup/pluginutils': 5.0.2(rollup@3.21.0) - rollup: 3.21.0 + '@rollup/pluginutils': 5.0.2(rollup@3.28.0) + rollup: 3.28.0 source-map: 0.7.4 vfile: 5.3.6 transitivePeerDependencies: @@ -3612,7 +3830,7 @@ packages: rollup: 3.12.0 dev: true - /@rollup/pluginutils@5.0.2(rollup@3.21.0): + /@rollup/pluginutils@5.0.2(rollup@3.28.0): resolution: {integrity: sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==} engines: {node: '>=14.0.0'} peerDependencies: @@ -3624,7 +3842,7 @@ packages: '@types/estree': 1.0.0 estree-walker: 2.0.2 picomatch: 2.3.1 - rollup: 3.21.0 + rollup: 3.28.0 dev: false /@sigstore/protobuf-specs@0.1.0: @@ -4554,7 +4772,7 @@ packages: vite-plugin-pwa: ^0.14.0 dependencies: astro: 2.3.2(@types/node@18.16.3) - vite-plugin-pwa: 0.14.7(vite@4.3.3)(workbox-build@6.5.4)(workbox-window@6.5.4) + vite-plugin-pwa: 0.14.7(vite@4.4.5)(workbox-build@6.5.4)(workbox-window@6.5.4) dev: true /@vitejs/plugin-react@4.0.0(vite@4.3.3): @@ -5036,13 +5254,14 @@ packages: typescript: 4.9.4 unist-util-visit: 4.1.2 vfile: 5.3.6 - vite: 4.3.3(@types/node@18.16.3) - vitefu: 0.2.4(vite@4.3.3) + vite: 4.4.5(@types/node@18.16.3) + vitefu: 0.2.4(vite@4.4.5) yargs-parser: 21.1.1 zod: 3.21.4 transitivePeerDependencies: - '@types/node' - less + - lightningcss - sass - stylus - sugarss @@ -6478,6 +6697,36 @@ packages: '@esbuild/win32-arm64': 0.17.18 '@esbuild/win32-ia32': 0.17.18 '@esbuild/win32-x64': 0.17.18 + dev: true + + /esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 /escalade@3.1.1: resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} @@ -10854,6 +11103,14 @@ packages: picocolors: 1.0.0 source-map-js: 1.0.2 + /postcss@8.4.27: + resolution: {integrity: sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.6 + picocolors: 1.0.0 + source-map-js: 1.0.2 + /prebuild-install@7.1.1: resolution: {integrity: sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==} engines: {node: '>=10'} @@ -11658,6 +11915,14 @@ packages: hasBin: true optionalDependencies: fsevents: 2.3.2 + dev: true + + /rollup@3.28.0: + resolution: {integrity: sha512-d7zhvo1OUY2SXSM6pfNjgD5+d0Nz87CUp4mt8l/GgVP3oBsPwzNvSzyu1me6BSG9JIgWNTVcafIXBIyM8yQ3yw==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.2 /run-async@2.4.1: resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} @@ -13063,10 +13328,11 @@ packages: mlly: 1.4.0 pathe: 1.1.1 picocolors: 1.0.0 - vite: 4.3.3(@types/node@18.16.3) + vite: 4.4.5(@types/node@18.16.3) transitivePeerDependencies: - '@types/node' - less + - lightningcss - sass - stylus - sugarss @@ -13074,7 +13340,7 @@ packages: - terser dev: true - /vite-plugin-pwa@0.14.7(vite@4.3.3)(workbox-build@6.5.4)(workbox-window@6.5.4): + /vite-plugin-pwa@0.14.7(vite@4.4.5)(workbox-build@6.5.4)(workbox-window@6.5.4): resolution: {integrity: sha512-dNJaf0fYOWncmjxv9HiSa2xrSjipjff7IkYE5oIUJ2x5HKu3cXgA8LRgzOwTc5MhwyFYRSU0xyN0Phbx3NsQYw==} peerDependencies: vite: ^3.1.0 || ^4.0.0 @@ -13086,7 +13352,7 @@ packages: fast-glob: 3.2.12 pretty-bytes: 6.1.0 rollup: 3.12.0 - vite: 4.3.3(@types/node@18.16.3) + vite: 4.4.5(@types/node@18.16.3) workbox-build: 6.5.4 workbox-window: 6.5.4 transitivePeerDependencies: @@ -13124,8 +13390,44 @@ packages: rollup: 3.21.0 optionalDependencies: fsevents: 2.3.2 + dev: true - /vitefu@0.2.4(vite@4.3.3): + /vite@4.4.5(@types/node@18.16.3): + resolution: {integrity: sha512-4m5kEtAWHYr0O1Fu7rZp64CfO1PsRGZlD3TAB32UmQlpd7qg15VF7ROqGN5CyqN7HFuwr7ICNM2+fDWRqFEKaA==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + '@types/node': 18.16.3 + esbuild: 0.18.20 + postcss: 8.4.27 + rollup: 3.28.0 + optionalDependencies: + fsevents: 2.3.2 + + /vitefu@0.2.4(vite@4.4.5): resolution: {integrity: sha512-fanAXjSaf9xXtOOeno8wZXIhgia+CZury481LsDaV++lSvcU2R9Ch2bPh3PYFyoHW+w9LqAeYRISVQjUIew14g==} peerDependencies: vite: ^3.0.0 || ^4.0.0 @@ -13133,7 +13435,7 @@ packages: vite: optional: true dependencies: - vite: 4.3.3(@types/node@18.16.3) + vite: 4.4.5(@types/node@18.16.3) /vitest@0.33.0(@vitest/ui@0.28.0): resolution: {integrity: sha512-1CxaugJ50xskkQ0e969R/hW47za4YXDUfWJDxip1hwbnhUjYolpfUn2AMOulqG/Dtd9WYAtkHmM/m3yKVrEejQ==} @@ -13193,6 +13495,7 @@ packages: why-is-node-running: 2.2.2 transitivePeerDependencies: - less + - lightningcss - sass - stylus - sugarss diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 984f2447..615475e4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,3 +6,4 @@ packages: - "packages/core/examples/vite-vanilla-repl-cm6" - "packages/react/examples/nano-repl" - "packages/web/examples/repl-example" + - "packages/superdough/example" From 2778e4d69788027accc6509777d5c6d9c810a60e Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 11 Aug 2023 10:43:59 +0200 Subject: [PATCH 02/13] fix: build entry file --- packages/superdough/vite.config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/superdough/vite.config.js b/packages/superdough/vite.config.js index ccf68b35..d6786f44 100644 --- a/packages/superdough/vite.config.js +++ b/packages/superdough/vite.config.js @@ -7,9 +7,9 @@ export default defineConfig({ plugins: [], build: { lib: { - entry: resolve(__dirname, 'superdough.mjs'), + entry: resolve(__dirname, 'index.mjs'), formats: ['es', 'cjs'], - fileName: (ext) => ({ es: 'superdough.mjs', cjs: 'superdough.cjs' }[ext]), + fileName: (ext) => ({ es: 'index.mjs', cjs: 'index.cjs' }[ext]), }, rollupOptions: { external: [...Object.keys(dependencies)], From 82cb4c8c11cfc4327080cf09345e3eca497c1ac2 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 11 Aug 2023 10:51:59 +0200 Subject: [PATCH 03/13] fix: still had strudel core imports --- packages/superdough/README.md | 5 +++-- packages/superdough/example/main.js | 16 ++++++++-------- packages/superdough/package.json | 2 +- packages/superdough/sampler.mjs | 12 ++++++------ packages/superdough/util.mjs | 24 ++++++++++++++++++++++++ 5 files changed, 42 insertions(+), 17 deletions(-) diff --git a/packages/superdough/README.md b/packages/superdough/README.md index 7a441841..4d09e7fd 100644 --- a/packages/superdough/README.md +++ b/packages/superdough/README.md @@ -17,10 +17,11 @@ npm i superdough --save ```js import { superdough, samples } from 'superdough'; // load samples from github -const loadSamples = samples('github:tidalcycles/Dirt-Samples/master'); +const init = samples('github:tidalcycles/Dirt-Samples/master'); // play some sounds when a button is clicked -document.getElementById('play').addEventListener('click', () => { +document.getElementById('play').addEventListener('click', async () => { + await init; superdough({ s: "bd", delay: .5 }, 0); superdough({ s: "sawtooth", cutoff: 600, resonance: 8 }, 0); superdough({ s: "hh" }, 0.25); diff --git a/packages/superdough/example/main.js b/packages/superdough/example/main.js index 50861938..7b74fb40 100644 --- a/packages/superdough/example/main.js +++ b/packages/superdough/example/main.js @@ -1,12 +1,13 @@ import { superdough, samples, initAudioOnFirstClick, registerSynthSounds } from 'superdough'; -initAudioOnFirstClick(); - -const load = Promise.all([samples('github:tidalcycles/Dirt-Samples/master'), registerSynthSounds()]); - -let button = document.getElementById('play'); +const init = Promise.all([ + initAudioOnFirstClick(), + samples('github:tidalcycles/Dirt-Samples/master'), + registerSynthSounds(), +]); const loop = (t = 0) => { + // superdough(value, time, duration) superdough({ s: 'bd', delay: 0.5 }, t); superdough({ note: 'g1', s: 'sawtooth', cutoff: 600, resonance: 8 }, t, 0.125); superdough({ note: 'g2', s: 'sawtooth', cutoff: 600, resonance: 8 }, t + 0.25, 0.125); @@ -15,9 +16,8 @@ const loop = (t = 0) => { superdough({ s: 'hh' }, t + 0.75); }; -button.addEventListener('click', async () => { - console.log('play'); - await load; +document.getElementById('play').addEventListener('click', async () => { + await init; let t = 0.1; while (t < 16) { loop(t++); diff --git a/packages/superdough/package.json b/packages/superdough/package.json index 1065d3f8..6f487cd0 100644 --- a/packages/superdough/package.json +++ b/packages/superdough/package.json @@ -1,6 +1,6 @@ { "name": "superdough", - "version": "0.9.0", + "version": "0.9.1", "description": "simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.", "main": "index.mjs", "type": "module", diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs index 17275a99..0df5ec15 100644 --- a/packages/superdough/sampler.mjs +++ b/packages/superdough/sampler.mjs @@ -1,4 +1,4 @@ -import { logger, noteToMidi, valueToMidi } from '@strudel.cycles/core'; +import { noteToMidi, valueToMidi } from './util.mjs'; import { getAudioContext, registerSound } from './index.mjs'; import { getEnvelope } from './helpers.mjs'; @@ -24,7 +24,7 @@ function humanFileSize(bytes, si) { export const getSampleBufferSource = async (s, n, note, speed, freq, bank, resolveUrl) => { let transpose = 0; if (freq !== undefined && note !== undefined) { - logger('[sampler] hap has note and freq. ignoring note', 'warning'); + // logger('[sampler] hap has note and freq. ignoring note', 'warning'); } let midi = valueToMidi({ freq, note }, 36); transpose = midi - 36; // C3 is middle C @@ -64,7 +64,7 @@ export const getSampleBufferSource = async (s, n, note, speed, freq, bank, resol export const loadBuffer = (url, ac, s, n = 0) => { const label = s ? `sound "${s}:${n}"` : 'sample'; if (!loadCache[url]) { - logger(`[sampler] load ${label}..`, 'load-sample', { url }); + //logger(`[sampler] load ${label}..`, 'load-sample', { url }); const timestamp = Date.now(); loadCache[url] = fetch(url) .then((res) => res.arrayBuffer()) @@ -72,7 +72,7 @@ export const loadBuffer = (url, ac, s, n = 0) => { const took = Date.now() - timestamp; const size = humanFileSize(res.byteLength); // const downSpeed = humanFileSize(res.byteLength / took); - logger(`[sampler] load ${label}... done! loaded ${size} in ${took}ms`, 'loaded-sample', { url }); + //logger(`[sampler] load ${label}... done! loaded ${size} in ${took}ms`, 'loaded-sample', { url }); const decoded = await ac.decodeAudioData(res); bufferCache[url] = decoded; return decoded; @@ -224,12 +224,12 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) { // asny stuff above took too long? if (ac.currentTime > t) { - logger(`[sampler] still loading sound "${s}:${n}"`, 'highlight'); + //logger(`[sampler] still loading sound "${s}:${n}"`, 'highlight'); // console.warn('sample still loading:', s, n); return; } if (!bufferSource) { - logger(`[sampler] could not load "${s}:${n}"`, 'error'); + //logger(`[sampler] could not load "${s}:${n}"`, 'error'); return; } bufferSource.playbackRate.value = Math.abs(speed) * bufferSource.playbackRate.value; diff --git a/packages/superdough/util.mjs b/packages/superdough/util.mjs index 860aa9c1..db056376 100644 --- a/packages/superdough/util.mjs +++ b/packages/superdough/util.mjs @@ -27,3 +27,27 @@ export const midiToFreq = (n) => { return Math.pow(2, (n - 69) / 12) * 440; }; export const clamp = (num, min, max) => Math.min(Math.max(num, min), max); + +export const freqToMidi = (freq) => { + return (12 * Math.log(freq / 440)) / Math.LN2 + 69; +}; + +export const valueToMidi = (value, fallbackValue) => { + if (typeof value !== 'object') { + throw new Error('valueToMidi: expected object value'); + } + let { freq, note } = value; + if (typeof freq === 'number') { + return freqToMidi(freq); + } + if (typeof note === 'string') { + return noteToMidi(note); + } + if (typeof note === 'number') { + return note; + } + if (!fallbackValue) { + throw new Error('valueToMidi: expected freq or note to be set'); + } + return fallbackValue; +}; From 8d9be21c8a0d5122635819612f33009d2cd14198 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 11 Aug 2023 10:56:57 +0200 Subject: [PATCH 04/13] better code example --- packages/superdough/README.md | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/superdough/README.md b/packages/superdough/README.md index 4d09e7fd..ccaa1e8c 100644 --- a/packages/superdough/README.md +++ b/packages/superdough/README.md @@ -15,19 +15,31 @@ npm i superdough --save ## Use ```js -import { superdough, samples } from 'superdough'; -// load samples from github -const init = samples('github:tidalcycles/Dirt-Samples/master'); +import { superdough, samples, initAudioOnFirstClick, registerSynthSounds } from 'superdough'; + +const init = Promise.all([ + initAudioOnFirstClick(), + samples('github:tidalcycles/Dirt-Samples/master'), + registerSynthSounds(), +]); + +const loop = (t = 0) => { + // superdough(value, time, duration) + superdough({ s: 'bd', delay: 0.5 }, t); + superdough({ note: 'g1', s: 'sawtooth', cutoff: 600, resonance: 8 }, t, 0.125); + superdough({ note: 'g2', s: 'sawtooth', cutoff: 600, resonance: 8 }, t + 0.25, 0.125); + superdough({ s: 'hh' }, t + 0.25); + superdough({ s: 'sd', room: 0.5 }, t + 0.5); + superdough({ s: 'hh' }, t + 0.75); +}; -// play some sounds when a button is clicked document.getElementById('play').addEventListener('click', async () => { await init; - superdough({ s: "bd", delay: .5 }, 0); - superdough({ s: "sawtooth", cutoff: 600, resonance: 8 }, 0); - superdough({ s: "hh" }, 0.25); - superdough({ s: "sd", room: .5 }, 0.5); - superdough({ s: "hh" }, 0.75); -}) + let t = 0.1; + while (t < 16) { + loop(t++); + } +}); ``` ## Credits From 7f44d9cdd25d8dfa2b54444a42617027f9383eb8 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 11 Aug 2023 11:09:33 +0200 Subject: [PATCH 05/13] add link to codesandbox --- packages/superdough/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/superdough/README.md b/packages/superdough/README.md index ccaa1e8c..b46ba7b5 100644 --- a/packages/superdough/README.md +++ b/packages/superdough/README.md @@ -42,6 +42,8 @@ document.getElementById('play').addEventListener('click', async () => { }); ``` +[Open this in Codesandbox](https://codesandbox.io/s/superdough-demo-forked-sf8djh?file=/src/index.js) + ## Credits - [SuperDirt](https://github.com/musikinformatik/SuperDirt) From 898bfaeecff2fb8f10c73de0f9aeff4a00fad51a Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 11 Aug 2023 11:13:31 +0200 Subject: [PATCH 06/13] bump to 0.9.2 --- packages/superdough/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/superdough/package.json b/packages/superdough/package.json index 6f487cd0..a3811f0a 100644 --- a/packages/superdough/package.json +++ b/packages/superdough/package.json @@ -1,6 +1,6 @@ { "name": "superdough", - "version": "0.9.1", + "version": "0.9.2", "description": "simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.", "main": "index.mjs", "type": "module", From 738e714f4580eaf27aa7c3d2699300bdb8c63161 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 11 Aug 2023 12:13:24 +0200 Subject: [PATCH 07/13] use superdough in webaudio package --- packages/superdough/superdough.mjs | 3 +- packages/webaudio/README.md | 1 + packages/webaudio/feedbackdelay.mjs | 31 --- packages/webaudio/helpers.mjs | 70 ------- packages/webaudio/index.mjs | 4 +- packages/webaudio/package.json | 2 +- packages/webaudio/reverb.mjs | 23 --- packages/webaudio/sampler.mjs | 288 ---------------------------- packages/webaudio/synth.mjs | 44 ----- packages/webaudio/vowel.mjs | 38 ---- packages/webaudio/webaudio.mjs | 240 +---------------------- packages/webaudio/worklets.mjs | 108 ----------- pnpm-lock.yaml | 6 +- 13 files changed, 13 insertions(+), 845 deletions(-) delete mode 100644 packages/webaudio/feedbackdelay.mjs delete mode 100644 packages/webaudio/helpers.mjs delete mode 100644 packages/webaudio/reverb.mjs delete mode 100644 packages/webaudio/sampler.mjs delete mode 100644 packages/webaudio/synth.mjs delete mode 100644 packages/webaudio/vowel.mjs delete mode 100644 packages/webaudio/worklets.mjs diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index a413ee9a..4bb8b9f5 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -124,7 +124,7 @@ function effectSend(input, effect, wet) { return send; } -export const superdough = async (value, deadline, hapDuration, cps) => { +export const superdough = async (value, deadline, hapDuration) => { const ac = getAudioContext(); if (typeof value !== 'object') { throw new Error( @@ -164,7 +164,6 @@ export const superdough = async (value, deadline, hapDuration, cps) => { size = 2, velocity = 1, } = value; - // const { velocity = 1 } = hap.context; gain *= velocity; // legacy fix for velocity let toDisconnect = []; // audio nodes that will be disconnected when the source has ended const onended = () => { diff --git a/packages/webaudio/README.md b/packages/webaudio/README.md index 4f2f8865..3eb64593 100644 --- a/packages/webaudio/README.md +++ b/packages/webaudio/README.md @@ -1,6 +1,7 @@ # @strudel.cycles/webaudio This package contains helpers to make music with strudel and the Web Audio API. +It is a thin binding to [superdough](https://www.npmjs.com/package/superdough). ## Install diff --git a/packages/webaudio/feedbackdelay.mjs b/packages/webaudio/feedbackdelay.mjs deleted file mode 100644 index c182d655..00000000 --- a/packages/webaudio/feedbackdelay.mjs +++ /dev/null @@ -1,31 +0,0 @@ -if (typeof DelayNode !== 'undefined') { - class FeedbackDelayNode extends DelayNode { - constructor(ac, wet, time, feedback) { - super(ac); - wet = Math.abs(wet); - this.delayTime.value = time; - - const feedbackGain = ac.createGain(); - feedbackGain.gain.value = Math.min(Math.abs(feedback), 0.995); - this.feedback = feedbackGain.gain; - - const delayGain = ac.createGain(); - delayGain.gain.value = wet; - this.delayGain = delayGain; - - this.connect(feedbackGain); - this.connect(delayGain); - feedbackGain.connect(this); - - this.connect = (target) => delayGain.connect(target); - return this; - } - start(t) { - this.delayGain.gain.setValueAtTime(this.delayGain.gain.value, t + this.delayTime.value); - } - } - - AudioContext.prototype.createFeedbackDelay = function (wet, time, feedback) { - return new FeedbackDelayNode(this, wet, time, feedback); - }; -} diff --git a/packages/webaudio/helpers.mjs b/packages/webaudio/helpers.mjs deleted file mode 100644 index 108dfb02..00000000 --- a/packages/webaudio/helpers.mjs +++ /dev/null @@ -1,70 +0,0 @@ -import { getAudioContext } from './webaudio.mjs'; - -export function gainNode(value) { - const node = getAudioContext().createGain(); - node.gain.value = value; - return node; -} - -export const getOscillator = ({ s, freq, t }) => { - // make oscillator - const o = getAudioContext().createOscillator(); - o.type = s || 'triangle'; - o.frequency.value = Number(freq); - o.start(t); - //o.stop(t + duration + release); - const stop = (time) => o.stop(time); - return { node: o, stop }; -}; - -// alternative to getADSR returning the gain node and a stop handle to trigger the release anytime in the future -export const getEnvelope = (attack, decay, sustain, release, velocity, begin) => { - const gainNode = getAudioContext().createGain(); - gainNode.gain.setValueAtTime(0, begin); - gainNode.gain.linearRampToValueAtTime(velocity, begin + attack); // attack - gainNode.gain.linearRampToValueAtTime(sustain * velocity, begin + attack + decay); // sustain start - // sustain end - return { - node: gainNode, - stop: (t) => { - //if (typeof gainNode.gain.cancelAndHoldAtTime === 'function') { - // gainNode.gain.cancelAndHoldAtTime(t); // this seems to release instantly.... - // see https://discord.com/channels/779427371270275082/937365093082079272/1086053607360712735 - //} else { - // firefox: this will glitch when the sustain has not been reached yet at the time of release - gainNode.gain.setValueAtTime(sustain * velocity, t); - //} - gainNode.gain.linearRampToValueAtTime(0, t + release); - }, - }; -}; - -export const getADSR = (attack, decay, sustain, release, velocity, begin, end) => { - const gainNode = getAudioContext().createGain(); - gainNode.gain.setValueAtTime(0, begin); - gainNode.gain.linearRampToValueAtTime(velocity, begin + attack); // attack - gainNode.gain.linearRampToValueAtTime(sustain * velocity, begin + attack + decay); // sustain start - gainNode.gain.setValueAtTime(sustain * velocity, end); // sustain end - gainNode.gain.linearRampToValueAtTime(0, end + release); // release - // for some reason, using exponential ramping creates little cracklings - /* let t = begin; - gainNode.gain.setValueAtTime(0, t); - gainNode.gain.exponentialRampToValueAtTime(velocity, (t += attack)); - const sustainGain = Math.max(sustain * velocity, 0.001); - gainNode.gain.exponentialRampToValueAtTime(sustainGain, (t += decay)); - if (end - begin < attack + decay) { - gainNode.gain.cancelAndHoldAtTime(end); - } else { - gainNode.gain.setValueAtTime(sustainGain, end); - } - gainNode.gain.exponentialRampToValueAtTime(0.001, end + release); // release */ - return gainNode; -}; - -export const getFilter = (type, frequency, Q) => { - const filter = getAudioContext().createBiquadFilter(); - filter.type = type; - filter.frequency.value = frequency; - filter.Q.value = Q; - return filter; -}; diff --git a/packages/webaudio/index.mjs b/packages/webaudio/index.mjs index 2ab74c72..563a367e 100644 --- a/packages/webaudio/index.mjs +++ b/packages/webaudio/index.mjs @@ -5,6 +5,4 @@ This program is free software: you can redistribute it and/or modify it under th */ export * from './webaudio.mjs'; -export * from './sampler.mjs'; -export * from './helpers.mjs'; -export * from './synth.mjs'; +export * from 'superdough'; diff --git a/packages/webaudio/package.json b/packages/webaudio/package.json index 0dc58877..edd53cbd 100644 --- a/packages/webaudio/package.json +++ b/packages/webaudio/package.json @@ -35,7 +35,7 @@ "homepage": "https://github.com/tidalcycles/strudel#readme", "dependencies": { "@strudel.cycles/core": "workspace:*", - "nanostores": "^0.8.1" + "superdough": "workspace:*" }, "devDependencies": { "vite": "^4.3.3" diff --git a/packages/webaudio/reverb.mjs b/packages/webaudio/reverb.mjs deleted file mode 100644 index e6d31f6a..00000000 --- a/packages/webaudio/reverb.mjs +++ /dev/null @@ -1,23 +0,0 @@ -if (typeof AudioContext !== 'undefined') { - AudioContext.prototype.impulseResponse = function (duration, channels = 1) { - const length = this.sampleRate * duration; - const impulse = this.createBuffer(channels, length, this.sampleRate); - const IR = impulse.getChannelData(0); - for (let i = 0; i < length; i++) IR[i] = (2 * Math.random() - 1) * Math.pow(1 - i / length, duration); - return impulse; - }; - - AudioContext.prototype.createReverb = function (duration) { - const convolver = this.createConvolver(); - convolver.setDuration = (d) => { - convolver.buffer = this.impulseResponse(d); - convolver.duration = duration; - return convolver; - }; - convolver.setDuration(duration); - return convolver; - }; -} - -// TODO: make the reverb more exciting -// check out https://blog.gskinner.com/archives/2019/02/reverb-web-audio-api.html diff --git a/packages/webaudio/sampler.mjs b/packages/webaudio/sampler.mjs deleted file mode 100644 index 17275a99..00000000 --- a/packages/webaudio/sampler.mjs +++ /dev/null @@ -1,288 +0,0 @@ -import { logger, noteToMidi, valueToMidi } from '@strudel.cycles/core'; -import { getAudioContext, registerSound } from './index.mjs'; -import { getEnvelope } from './helpers.mjs'; - -const bufferCache = {}; // string: Promise -const loadCache = {}; // string: Promise - -export const getCachedBuffer = (url) => bufferCache[url]; - -function humanFileSize(bytes, si) { - var thresh = si ? 1000 : 1024; - if (bytes < thresh) return bytes + ' B'; - var units = si - ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] - : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; - var u = -1; - do { - bytes /= thresh; - ++u; - } while (bytes >= thresh); - return bytes.toFixed(1) + ' ' + units[u]; -} - -export const getSampleBufferSource = async (s, n, note, speed, freq, bank, resolveUrl) => { - let transpose = 0; - if (freq !== undefined && note !== undefined) { - logger('[sampler] hap has note and freq. ignoring note', 'warning'); - } - let midi = valueToMidi({ freq, note }, 36); - transpose = midi - 36; // C3 is middle C - - const ac = getAudioContext(); - let sampleUrl; - if (Array.isArray(bank)) { - sampleUrl = bank[n % bank.length]; - } else { - const midiDiff = (noteA) => noteToMidi(noteA) - midi; - // object format will expect keys as notes - const closest = Object.keys(bank) - .filter((k) => !k.startsWith('_')) - .reduce( - (closest, key, j) => (!closest || Math.abs(midiDiff(key)) < Math.abs(midiDiff(closest)) ? key : closest), - null, - ); - transpose = -midiDiff(closest); // semitones to repitch - sampleUrl = bank[closest][n % bank[closest].length]; - } - if (resolveUrl) { - sampleUrl = await resolveUrl(sampleUrl); - } - let buffer = await loadBuffer(sampleUrl, ac, s, n); - if (speed < 0) { - // should this be cached? - buffer = reverseBuffer(buffer); - } - const bufferSource = ac.createBufferSource(); - bufferSource.buffer = buffer; - const playbackRate = 1.0 * Math.pow(2, transpose / 12); - // bufferSource.playbackRate.value = Math.pow(2, transpose / 12); - bufferSource.playbackRate.value = playbackRate; - return bufferSource; -}; - -export const loadBuffer = (url, ac, s, n = 0) => { - const label = s ? `sound "${s}:${n}"` : 'sample'; - if (!loadCache[url]) { - logger(`[sampler] load ${label}..`, 'load-sample', { url }); - const timestamp = Date.now(); - loadCache[url] = fetch(url) - .then((res) => res.arrayBuffer()) - .then(async (res) => { - const took = Date.now() - timestamp; - const size = humanFileSize(res.byteLength); - // const downSpeed = humanFileSize(res.byteLength / took); - logger(`[sampler] load ${label}... done! loaded ${size} in ${took}ms`, 'loaded-sample', { url }); - const decoded = await ac.decodeAudioData(res); - bufferCache[url] = decoded; - return decoded; - }); - } - return loadCache[url]; -}; - -export function reverseBuffer(buffer) { - const ac = getAudioContext(); - const reversed = ac.createBuffer(buffer.numberOfChannels, buffer.length, ac.sampleRate); - for (let channel = 0; channel < buffer.numberOfChannels; channel++) { - reversed.copyToChannel(buffer.getChannelData(channel).slice().reverse(), channel, channel); - } - return reversed; -} - -export const getLoadedBuffer = (url) => { - return bufferCache[url]; -}; - -export const processSampleMap = (sampleMap, fn, baseUrl = sampleMap._base || '') => { - return Object.entries(sampleMap).forEach(([key, value]) => { - if (typeof value === 'string') { - value = [value]; - } - if (typeof value !== 'object') { - throw new Error('wrong sample map format for ' + key); - } - baseUrl = value._base || baseUrl; - const replaceUrl = (v) => (baseUrl + v).replace('github:', 'https://raw.githubusercontent.com/'); - if (Array.isArray(value)) { - //return [key, value.map(replaceUrl)]; - value = value.map(replaceUrl); - } else { - // must be object - value = Object.fromEntries( - Object.entries(value).map(([note, samples]) => { - return [note, (typeof samples === 'string' ? [samples] : samples).map(replaceUrl)]; - }), - ); - } - fn(key, value); - }); -}; - -// allows adding a custom url prefix handler -// for example, it is used by the desktop app to load samples starting with '~/music' -let resourcePrefixHandlers = {}; -export function registerSamplesPrefix(prefix, resolve) { - resourcePrefixHandlers[prefix] = resolve; -} -// finds a prefix handler for the given url (if any) -function getSamplesPrefixHandler(url) { - const handler = Object.entries(resourcePrefixHandlers).find(([key]) => url.startsWith(key)); - if (handler) { - return handler[1]; - } - return; -} - -/** - * Loads a collection of samples to use with `s` - * @example - * samples('github:tidalcycles/Dirt-Samples/master'); - * s("[bd ~]*2, [~ hh]*2, ~ sd") - * @example - * samples({ - * bd: '808bd/BD0000.WAV', - * sd: '808sd/SD0010.WAV' - * }, 'https://raw.githubusercontent.com/tidalcycles/Dirt-Samples/master/'); - * s("[bd ~]*2, [~ hh]*2, ~ sd") - * - */ - -export const samples = async (sampleMap, baseUrl = sampleMap._base || '', options = {}) => { - if (typeof sampleMap === 'string') { - // check if custom prefix handler - const handler = getSamplesPrefixHandler(sampleMap); - if (handler) { - return handler(sampleMap); - } - if (sampleMap.startsWith('github:')) { - let [_, path] = sampleMap.split('github:'); - path = path.endsWith('/') ? path.slice(0, -1) : path; - sampleMap = `https://raw.githubusercontent.com/${path}/strudel.json`; - } - if (typeof fetch !== 'function') { - // not a browser - return; - } - const base = sampleMap.split('/').slice(0, -1).join('/'); - if (typeof fetch === 'undefined') { - // skip fetch when in node / testing - return; - } - return fetch(sampleMap) - .then((res) => res.json()) - .then((json) => samples(json, baseUrl || json._base || base, options)) - .catch((error) => { - console.error(error); - throw new Error(`error loading "${sampleMap}"`); - }); - } - const { prebake, tag } = options; - processSampleMap( - sampleMap, - (key, value) => - registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, value), { - type: 'sample', - samples: value, - baseUrl, - prebake, - tag, - }), - baseUrl, - ); -}; - -const cutGroups = []; - -export async function onTriggerSample(t, value, onended, bank, resolveUrl) { - const { - s, - freq, - unit, - nudge = 0, // TODO: is this in seconds? - cut, - loop, - clip = undefined, // if 1, samples will be cut off when the hap ends - n = 0, - note, - speed = 1, // sample playback speed - begin = 0, - end = 1, - } = value; - // load sample - if (speed === 0) { - // no playback - return; - } - const ac = getAudioContext(); - // destructure adsr here, because the default should be different for synths and samples - const { attack = 0.001, decay = 0.001, sustain = 1, release = 0.001 } = value; - //const soundfont = getSoundfontKey(s); - const time = t + nudge; - - const bufferSource = await getSampleBufferSource(s, n, note, speed, freq, bank, resolveUrl); - - // asny stuff above took too long? - if (ac.currentTime > t) { - logger(`[sampler] still loading sound "${s}:${n}"`, 'highlight'); - // console.warn('sample still loading:', s, n); - return; - } - if (!bufferSource) { - logger(`[sampler] could not load "${s}:${n}"`, 'error'); - return; - } - bufferSource.playbackRate.value = Math.abs(speed) * bufferSource.playbackRate.value; - if (unit === 'c') { - // are there other units? - bufferSource.playbackRate.value = bufferSource.playbackRate.value * bufferSource.buffer.duration * 1; //cps; - } - // "The computation of the offset into the sound is performed using the sound buffer's natural sample rate, - // rather than the current playback rate, so even if the sound is playing at twice its normal speed, - // the midway point through a 10-second audio buffer is still 5." - const offset = begin * bufferSource.buffer.duration; - bufferSource.start(time, offset); - const bufferDuration = bufferSource.buffer.duration / bufferSource.playbackRate.value; - /*if (loop) { - // TODO: idea for loopBegin / loopEnd - // if one of [loopBegin,loopEnd] is <= 1, interpret it as normlized - // if [loopBegin,loopEnd] is bigger >= 1, interpret it as sample number - // this will simplify perfectly looping things, while still keeping the normalized option - // the only drawback is that looping between samples 0 and 1 is not possible (which is not real use case) - bufferSource.loop = true; - bufferSource.loopStart = offset; - bufferSource.loopEnd = offset + duration; - duration = loop * duration; - }*/ - const { node: envelope, stop: releaseEnvelope } = getEnvelope(attack, decay, sustain, release, 1, t); - bufferSource.connect(envelope); - const out = ac.createGain(); // we need a separate gain for the cutgroups because firefox... - envelope.connect(out); - bufferSource.onended = function () { - bufferSource.disconnect(); - envelope.disconnect(); - out.disconnect(); - onended(); - }; - const stop = (endTime, playWholeBuffer = clip === undefined) => { - let releaseTime = endTime; - if (playWholeBuffer) { - releaseTime = t + (end - begin) * bufferDuration; - } - bufferSource.stop(releaseTime + release); - releaseEnvelope(releaseTime); - }; - const handle = { node: out, bufferSource, stop }; - - // cut groups - if (cut !== undefined) { - const prev = cutGroups[cut]; - if (prev) { - prev.node.gain.setValueAtTime(1, time); - prev.node.gain.linearRampToValueAtTime(0, time + 0.01); - } - cutGroups[cut] = handle; - } - - return handle; -} diff --git a/packages/webaudio/synth.mjs b/packages/webaudio/synth.mjs deleted file mode 100644 index 242c8fb4..00000000 --- a/packages/webaudio/synth.mjs +++ /dev/null @@ -1,44 +0,0 @@ -import { midiToFreq, noteToMidi } from '@strudel.cycles/core'; -import { registerSound } from './webaudio.mjs'; -import { getOscillator, gainNode, getEnvelope } from './helpers.mjs'; - -export function registerSynthSounds() { - ['sine', 'square', 'triangle', 'sawtooth'].forEach((wave) => { - registerSound( - wave, - (t, value, onended) => { - // destructure adsr here, because the default should be different for synths and samples - const { attack = 0.001, decay = 0.05, sustain = 0.6, release = 0.01 } = value; - let { n, note, freq } = value; - // with synths, n and note are the same thing - n = note || n || 36; - if (typeof n === 'string') { - n = noteToMidi(n); // e.g. c3 => 48 - } - // get frequency - if (!freq && typeof n === 'number') { - freq = midiToFreq(n); // + 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 }); - const g = gainNode(0.3); - // envelope - const { node: envelope, stop: releaseEnvelope } = getEnvelope(attack, decay, sustain, release, 1, t); - o.onended = () => { - o.disconnect(); - g.disconnect(); - onended(); - }; - return { - node: o.connect(g).connect(envelope), - stop: (releaseTime) => { - releaseEnvelope(releaseTime); - stop(releaseTime + release); - }, - }; - }, - { type: 'synth', prebake: true }, - ); - }); -} diff --git a/packages/webaudio/vowel.mjs b/packages/webaudio/vowel.mjs deleted file mode 100644 index 25357642..00000000 --- a/packages/webaudio/vowel.mjs +++ /dev/null @@ -1,38 +0,0 @@ -// credits to webdirt: https://github.com/dktr0/WebDirt/blob/41342e81d6ad694a2310d491fef7b7e8b0929efe/js-src/Graph.js#L597 -export var vowelFormant = { - a: { freqs: [660, 1120, 2750, 3000, 3350], gains: [1, 0.5012, 0.0708, 0.0631, 0.0126], qs: [80, 90, 120, 130, 140] }, - e: { freqs: [440, 1800, 2700, 3000, 3300], gains: [1, 0.1995, 0.1259, 0.1, 0.1], qs: [70, 80, 100, 120, 120] }, - i: { freqs: [270, 1850, 2900, 3350, 3590], gains: [1, 0.0631, 0.0631, 0.0158, 0.0158], qs: [40, 90, 100, 120, 120] }, - o: { freqs: [430, 820, 2700, 3000, 3300], gains: [1, 0.3162, 0.0501, 0.0794, 0.01995], qs: [40, 80, 100, 120, 120] }, - u: { freqs: [370, 630, 2750, 3000, 3400], gains: [1, 0.1, 0.0708, 0.0316, 0.01995], qs: [40, 60, 100, 120, 120] }, -}; -if (typeof GainNode !== 'undefined') { - class VowelNode extends GainNode { - constructor(ac, letter) { - super(ac); - if (!vowelFormant[letter]) { - throw new Error('vowel: unknown vowel ' + letter); - } - const { gains, qs, freqs } = vowelFormant[letter]; - const makeupGain = ac.createGain(); - for (let i = 0; i < 5; i++) { - const gain = ac.createGain(); - gain.gain.value = gains[i]; - const filter = ac.createBiquadFilter(); - filter.type = 'bandpass'; - filter.Q.value = qs[i]; - filter.frequency.value = freqs[i]; - this.connect(filter); - filter.connect(gain); - gain.connect(makeupGain); - } - makeupGain.gain.value = 8; // how much makeup gain to add? - this.connect = (target) => makeupGain.connect(target); - return this; - } - } - - AudioContext.prototype.createVowelFilter = function (letter) { - return new VowelNode(this, letter); - }; -} diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs index c5922303..6d01e37f 100644 --- a/packages/webaudio/webaudio.mjs +++ b/packages/webaudio/webaudio.mjs @@ -5,247 +5,19 @@ This program is free software: you can redistribute it and/or modify it under th */ import * as strudel from '@strudel.cycles/core'; -import './feedbackdelay.mjs'; -import './reverb.mjs'; +import { superdough, getAudioContext } from 'superdough'; const { Pattern, logger } = strudel; -import './vowel.mjs'; -import workletsUrl from './worklets.mjs?url'; -import { getFilter, gainNode } from './helpers.mjs'; -import { map } from 'nanostores'; -export const soundMap = map(); -export function registerSound(key, onTrigger, data = {}) { - soundMap.setKey(key, { onTrigger, data }); -} -export function getSound(s) { - return soundMap.get()[s]; -} -export const resetLoadedSounds = () => soundMap.set({}); - -let audioContext; -export const getAudioContext = () => { - if (!audioContext) { - audioContext = new AudioContext(); - } - return audioContext; -}; - -let destination; -const getDestination = () => { - const ctx = getAudioContext(); - if (!destination) { - destination = ctx.createGain(); - destination.connect(ctx.destination); - } - return destination; -}; - -export const panic = () => { - getDestination().gain.linearRampToValueAtTime(0, getAudioContext().currentTime + 0.01); - destination = null; -}; - -let workletsLoading; -function loadWorklets() { - if (workletsLoading) { - return workletsLoading; - } - workletsLoading = getAudioContext().audioWorklet.addModule(workletsUrl); - return workletsLoading; -} - -function getWorklet(ac, processor, params) { - const node = new AudioWorkletNode(ac, processor); - Object.entries(params).forEach(([key, value]) => { - node.parameters.get(key).value = value; - }); - return node; -} - -// this function should be called on first user interaction (to avoid console warning) -export async function initAudio() { - if (typeof window !== 'undefined') { - try { - await getAudioContext().resume(); - await loadWorklets(); - } catch (err) { - console.warn('could not load AudioWorklet effects coarse, crush and shape', err); - } - } -} - -export async function initAudioOnFirstClick() { - return new Promise((resolve) => { - document.addEventListener('click', async function listener() { - await initAudio(); - resolve(); - document.removeEventListener('click', listener); - }); - }); -} - -let delays = {}; -const maxfeedback = 0.98; -function getDelay(orbit, delaytime, delayfeedback, t) { - if (delayfeedback > maxfeedback) { - logger(`delayfeedback was clamped to ${maxfeedback} to save your ears`); - } - delayfeedback = strudel.clamp(delayfeedback, 0, 0.98); - if (!delays[orbit]) { - const ac = getAudioContext(); - const dly = ac.createFeedbackDelay(1, delaytime, delayfeedback); - dly.start?.(t); // for some reason, this throws when audion extension is installed.. - dly.connect(getDestination()); - delays[orbit] = dly; - } - delays[orbit].delayTime.value !== delaytime && delays[orbit].delayTime.setValueAtTime(delaytime, t); - delays[orbit].feedback.value !== delayfeedback && delays[orbit].feedback.setValueAtTime(delayfeedback, t); - return delays[orbit]; -} - -let reverbs = {}; -function getReverb(orbit, duration = 2) { - if (!reverbs[orbit]) { - const ac = getAudioContext(); - const reverb = ac.createReverb(duration); - reverb.connect(getDestination()); - reverbs[orbit] = reverb; - } - if (reverbs[orbit].duration !== duration) { - reverbs[orbit] = reverbs[orbit].setDuration(duration); - reverbs[orbit].duration = duration; - } - return reverbs[orbit]; -} - -function effectSend(input, effect, wet) { - const send = gainNode(wet); - input.connect(send); - send.connect(effect); - return send; -} - -// export const webaudioOutput = async (t, hap, ct, cps) => { -export const webaudioOutput = async (hap, deadline, hapDuration, cps) => { - const ac = getAudioContext(); +const hap2value = (hap) => { hap.ensureObjectValue(); - - // calculate absolute time - let t = ac.currentTime + deadline; - // destructure - let { - s = 'triangle', - bank, - source, - gain = 0.8, - // low pass - cutoff, - resonance = 1, - // high pass - hcutoff, - hresonance = 1, - // band pass - bandf, - bandq = 1, - // - coarse, - crush, - shape, - pan, - vowel, - delay = 0, - delayfeedback = 0.5, - delaytime = 0.25, - orbit = 1, - room, - size = 2, - } = hap.value; - const { velocity = 1 } = hap.context; - gain *= velocity; // legacy fix for velocity - let toDisconnect = []; // audio nodes that will be disconnected when the source has ended - const onended = () => { - toDisconnect.forEach((n) => n?.disconnect()); - }; - if (bank && s) { - s = `${bank}_${s}`; - } - // get source AudioNode - let sourceNode; - if (source) { - sourceNode = source(t, hap.value, hapDuration); - } else if (getSound(s)) { - const { onTrigger } = getSound(s); - const soundHandle = await onTrigger(t, hap.value, onended); - if (soundHandle) { - sourceNode = soundHandle.node; - soundHandle.stop(t + hapDuration); - } - } else { - throw new Error(`sound ${s} not found! Is it loaded?`); - } - if (!sourceNode) { - // if onTrigger does not return anything, we will just silently skip - // this can be used for things like speed(0) in the sampler - return; - } - if (ac.currentTime > t) { - logger('[webaudio] skip hap: still loading', ac.currentTime - t); - return; - } - const chain = []; // audio nodes that will be connected to each other sequentially - chain.push(sourceNode); - - // gain stage - chain.push(gainNode(gain)); - - // filters - cutoff !== undefined && chain.push(getFilter('lowpass', cutoff, resonance)); - hcutoff !== undefined && chain.push(getFilter('highpass', hcutoff, hresonance)); - bandf !== undefined && chain.push(getFilter('bandpass', bandf, bandq)); - vowel !== undefined && chain.push(ac.createVowelFilter(vowel)); - - // effects - coarse !== undefined && chain.push(getWorklet(ac, 'coarse-processor', { coarse })); - crush !== undefined && chain.push(getWorklet(ac, 'crush-processor', { crush })); - shape !== undefined && chain.push(getWorklet(ac, 'shape-processor', { shape })); - - // panning - if (pan !== undefined) { - const panner = ac.createStereoPanner(); - panner.pan.value = 2 * pan - 1; - chain.push(panner); - } - - // last gain - const post = gainNode(1); - chain.push(post); - post.connect(getDestination()); - - // delay - let delaySend; - if (delay > 0 && delaytime > 0 && delayfeedback > 0) { - const delyNode = getDelay(orbit, delaytime, delayfeedback, t); - delaySend = effectSend(post, delyNode, delay); - } - // reverb - let reverbSend; - if (room > 0 && size > 0) { - const reverbNode = getReverb(orbit, size); - reverbSend = effectSend(post, reverbNode, room); - } - - // connect chain elements together - chain.slice(1).reduce((last, current) => last.connect(current), chain[0]); - - // toDisconnect = all the node that should be disconnected in onended callback - // this is crucial for performance - toDisconnect = chain.concat([delaySend, reverbSend]); + return { ...hap.value, velocity: hap.context.velocity }; }; -export const webaudioOutputTrigger = (t, hap, ct, cps) => webaudioOutput(hap, t - ct, hap.duration / cps, cps); +// TODO: bind logger +export const webaudioOutputTrigger = (t, hap, ct, cps) => superdough(hap2value(hap), t - ct, hap.duration / cps, cps); +export const webaudioOutput = (hap, deadline, hapDuration) => superdough(hap2value(hap), deadline, hapDuration); Pattern.prototype.webaudio = function () { - // TODO: refactor (t, hap, ct, cps) to (hap, deadline, duration) ? return this.onTrigger(webaudioOutputTrigger); }; diff --git a/packages/webaudio/worklets.mjs b/packages/webaudio/worklets.mjs deleted file mode 100644 index 7bb43f87..00000000 --- a/packages/webaudio/worklets.mjs +++ /dev/null @@ -1,108 +0,0 @@ -// LICENSE GNU General Public License v3.0 see https://github.com/dktr0/WebDirt/blob/main/LICENSE -// all the credit goes to dktr0's webdirt: https://github.com/dktr0/WebDirt/blob/5ce3d698362c54d6e1b68acc47eb2955ac62c793/dist/AudioWorklets.js -// <3 - -class CoarseProcessor extends AudioWorkletProcessor { - static get parameterDescriptors() { - return [{ name: 'coarse', defaultValue: 1 }]; - } - - constructor() { - super(); - this.notStarted = true; - } - - process(inputs, outputs, parameters) { - const input = inputs[0]; - const output = outputs[0]; - const coarse = parameters.coarse; - const blockSize = 128; - const hasInput = !(input[0] === undefined); - if (hasInput) { - this.notStarted = false; - output[0][0] = input[0][0]; - for (let n = 1; n < blockSize; n++) { - for (let o = 0; o < output.length; o++) { - output[o][n] = n % coarse == 0 ? input[0][n] : output[o][n - 1]; - } - } - } - return this.notStarted || hasInput; - } -} - -registerProcessor('coarse-processor', CoarseProcessor); - -class CrushProcessor extends AudioWorkletProcessor { - static get parameterDescriptors() { - return [{ name: 'crush', defaultValue: 0 }]; - } - - constructor() { - super(); - this.notStarted = true; - } - - process(inputs, outputs, parameters) { - const input = inputs[0]; - const output = outputs[0]; - const crush = parameters.crush; - const blockSize = 128; - const hasInput = !(input[0] === undefined); - if (hasInput) { - this.notStarted = false; - if (crush.length === 1) { - const x = Math.pow(2, crush[0] - 1); - for (let n = 0; n < blockSize; n++) { - const value = Math.round(input[0][n] * x) / x; - for (let o = 0; o < output.length; o++) { - output[o][n] = value; - } - } - } else { - for (let n = 0; n < blockSize; n++) { - let x = Math.pow(2, crush[n] - 1); - const value = Math.round(input[0][n] * x) / x; - for (let o = 0; o < output.length; o++) { - output[o][n] = value; - } - } - } - } - return this.notStarted || hasInput; - } -} -registerProcessor('crush-processor', CrushProcessor); - -class ShapeProcessor extends AudioWorkletProcessor { - static get parameterDescriptors() { - return [{ name: 'shape', defaultValue: 0 }]; - } - - constructor() { - super(); - this.notStarted = true; - } - - process(inputs, outputs, parameters) { - const input = inputs[0]; - const output = outputs[0]; - const shape0 = parameters.shape[0]; - const shape1 = shape0 < 1 ? shape0 : 1.0 - 4e-10; - const shape = (2.0 * shape1) / (1.0 - shape1); - const blockSize = 128; - const hasInput = !(input[0] === undefined); - if (hasInput) { - this.notStarted = false; - for (let n = 0; n < blockSize; n++) { - const value = ((1 + shape) * input[0][n]) / (1 + shape * Math.abs(input[0][n])); - for (let o = 0; o < output.length; o++) { - output[o][n] = value; - } - } - } - return this.notStarted || hasInput; - } -} - -registerProcessor('shape-processor', ShapeProcessor); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86d83437..643a4766 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -490,9 +490,9 @@ importers: '@strudel.cycles/core': specifier: workspace:* version: link:../core - nanostores: - specifier: ^0.8.1 - version: 0.8.1 + superdough: + specifier: workspace:* + version: link:../superdough devDependencies: vite: specifier: ^4.3.3 From 75281afe057b7c86e0063bef6a77e0ab142eb144 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 11 Aug 2023 12:20:36 +0200 Subject: [PATCH 08/13] rename: superdough.mjs > webaudio.mjs (to keep history?) --- packages/superdough/helpers.mjs | 2 +- packages/superdough/index.mjs | 2 +- packages/superdough/synth.mjs | 2 +- packages/superdough/{superdough.mjs => webaudio.mjs} | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) rename packages/superdough/{superdough.mjs => webaudio.mjs} (98%) diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 7cc54c8d..108dfb02 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -1,4 +1,4 @@ -import { getAudioContext } from './superdough.mjs'; +import { getAudioContext } from './webaudio.mjs'; export function gainNode(value) { const node = getAudioContext().createGain(); diff --git a/packages/superdough/index.mjs b/packages/superdough/index.mjs index 9b2067e6..4c735557 100644 --- a/packages/superdough/index.mjs +++ b/packages/superdough/index.mjs @@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see . */ -export * from './superdough.mjs'; +export * from './webaudio.mjs'; export * from './sampler.mjs'; export * from './helpers.mjs'; export * from './synth.mjs'; diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index 73174f65..e873aa03 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -1,5 +1,5 @@ import { midiToFreq, noteToMidi } from './util.mjs'; -import { registerSound } from './superdough.mjs'; +import { registerSound } from './webaudio.mjs'; import { getOscillator, gainNode, getEnvelope } from './helpers.mjs'; export function registerSynthSounds() { diff --git a/packages/superdough/superdough.mjs b/packages/superdough/webaudio.mjs similarity index 98% rename from packages/superdough/superdough.mjs rename to packages/superdough/webaudio.mjs index 4bb8b9f5..2f04018e 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/webaudio.mjs @@ -1,6 +1,6 @@ /* -superdough.mjs - -Copyright (C) 2022 Strudel contributors - see +webaudio.mjs - +Copyright (C) 2022 Strudel contributors - see This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ From 7d0fb0de83bcb96b331f90ca2539414f6b59db9b Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 11 Aug 2023 12:22:03 +0200 Subject: [PATCH 09/13] Revert "rename: superdough.mjs > webaudio.mjs" This reverts commit 75281afe057b7c86e0063bef6a77e0ab142eb144. --- packages/superdough/helpers.mjs | 2 +- packages/superdough/index.mjs | 2 +- packages/superdough/{webaudio.mjs => superdough.mjs} | 4 ++-- packages/superdough/synth.mjs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) rename packages/superdough/{webaudio.mjs => superdough.mjs} (98%) diff --git a/packages/superdough/helpers.mjs b/packages/superdough/helpers.mjs index 108dfb02..7cc54c8d 100644 --- a/packages/superdough/helpers.mjs +++ b/packages/superdough/helpers.mjs @@ -1,4 +1,4 @@ -import { getAudioContext } from './webaudio.mjs'; +import { getAudioContext } from './superdough.mjs'; export function gainNode(value) { const node = getAudioContext().createGain(); diff --git a/packages/superdough/index.mjs b/packages/superdough/index.mjs index 4c735557..9b2067e6 100644 --- a/packages/superdough/index.mjs +++ b/packages/superdough/index.mjs @@ -4,7 +4,7 @@ Copyright (C) 2022 Strudel contributors - see . */ -export * from './webaudio.mjs'; +export * from './superdough.mjs'; export * from './sampler.mjs'; export * from './helpers.mjs'; export * from './synth.mjs'; diff --git a/packages/superdough/webaudio.mjs b/packages/superdough/superdough.mjs similarity index 98% rename from packages/superdough/webaudio.mjs rename to packages/superdough/superdough.mjs index 2f04018e..4bb8b9f5 100644 --- a/packages/superdough/webaudio.mjs +++ b/packages/superdough/superdough.mjs @@ -1,6 +1,6 @@ /* -webaudio.mjs - -Copyright (C) 2022 Strudel contributors - see +superdough.mjs - +Copyright (C) 2022 Strudel contributors - see This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ diff --git a/packages/superdough/synth.mjs b/packages/superdough/synth.mjs index e873aa03..73174f65 100644 --- a/packages/superdough/synth.mjs +++ b/packages/superdough/synth.mjs @@ -1,5 +1,5 @@ import { midiToFreq, noteToMidi } from './util.mjs'; -import { registerSound } from './webaudio.mjs'; +import { registerSound } from './superdough.mjs'; import { getOscillator, gainNode, getEnvelope } from './helpers.mjs'; export function registerSynthSounds() { From c6095fd92a820711cb539d3bc46149f6d2c8e9dc Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 11 Aug 2023 12:35:00 +0200 Subject: [PATCH 10/13] add disableWorklets option to initAudio --- packages/superdough/superdough.mjs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 4bb8b9f5..33498d95 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -62,21 +62,24 @@ function getWorklet(ac, processor, params) { } // this function should be called on first user interaction (to avoid console warning) -export async function initAudio() { +export async function initAudio(options = {}) { + const { disableWorklets = false } = options; if (typeof window !== 'undefined') { - try { - await getAudioContext().resume(); - await loadWorklets(); - } catch (err) { - console.warn('could not load AudioWorklet effects coarse, crush and shape', err); + await getAudioContext().resume(); + if (!disableWorklets) { + await loadWorklets().catch((err) => { + console.warn('could not load AudioWorklet effects coarse, crush and shape', err); + }); + } else { + console.log('disableWorklets: AudioWorklet effects coarse, crush and shape are skipped!'); } } } -export async function initAudioOnFirstClick() { +export async function initAudioOnFirstClick(options) { return new Promise((resolve) => { document.addEventListener('click', async function listener() { - await initAudio(); + await initAudio(options); resolve(); document.removeEventListener('click', listener); }); From 0c6a0720af0e67821f074ca9718d72f5a94f3c9d Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Fri, 11 Aug 2023 12:35:18 +0200 Subject: [PATCH 11/13] bump superdough to 0.9.3 --- packages/superdough/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/superdough/package.json b/packages/superdough/package.json index a3811f0a..7f307e40 100644 --- a/packages/superdough/package.json +++ b/packages/superdough/package.json @@ -1,6 +1,6 @@ { "name": "superdough", - "version": "0.9.2", + "version": "0.9.3", "description": "simple web audio synth and sampler intended for live coding. inspired by superdirt and webdirt.", "main": "index.mjs", "type": "module", From 8bdbc9ec4c39c31cd9e5cb79485a394dbddd17b3 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 17 Aug 2023 10:08:55 +0200 Subject: [PATCH 12/13] superdough: add logger --- packages/superdough/index.mjs | 1 + packages/superdough/logger.mjs | 7 +++++++ packages/superdough/sampler.mjs | 11 ++++++----- packages/superdough/superdough.mjs | 3 ++- packages/webaudio/webaudio.mjs | 4 +++- 5 files changed, 19 insertions(+), 7 deletions(-) create mode 100644 packages/superdough/logger.mjs diff --git a/packages/superdough/index.mjs b/packages/superdough/index.mjs index 9b2067e6..b795539a 100644 --- a/packages/superdough/index.mjs +++ b/packages/superdough/index.mjs @@ -8,3 +8,4 @@ export * from './superdough.mjs'; export * from './sampler.mjs'; export * from './helpers.mjs'; export * from './synth.mjs'; +export * from './logger.mjs'; diff --git a/packages/superdough/logger.mjs b/packages/superdough/logger.mjs new file mode 100644 index 00000000..a20af1b3 --- /dev/null +++ b/packages/superdough/logger.mjs @@ -0,0 +1,7 @@ +let log = (msg) => console.log(msg); + +export const logger = (...args) => log(...args); + +export const setLogger = (fn) => { + log = fn; +}; diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs index 0df5ec15..02e5eada 100644 --- a/packages/superdough/sampler.mjs +++ b/packages/superdough/sampler.mjs @@ -1,6 +1,7 @@ import { noteToMidi, valueToMidi } from './util.mjs'; import { getAudioContext, registerSound } from './index.mjs'; import { getEnvelope } from './helpers.mjs'; +import { logger } from './logger.mjs'; const bufferCache = {}; // string: Promise const loadCache = {}; // string: Promise @@ -24,7 +25,7 @@ function humanFileSize(bytes, si) { export const getSampleBufferSource = async (s, n, note, speed, freq, bank, resolveUrl) => { let transpose = 0; if (freq !== undefined && note !== undefined) { - // logger('[sampler] hap has note and freq. ignoring note', 'warning'); + logger('[sampler] hap has note and freq. ignoring note', 'warning'); } let midi = valueToMidi({ freq, note }, 36); transpose = midi - 36; // C3 is middle C @@ -64,7 +65,7 @@ export const getSampleBufferSource = async (s, n, note, speed, freq, bank, resol export const loadBuffer = (url, ac, s, n = 0) => { const label = s ? `sound "${s}:${n}"` : 'sample'; if (!loadCache[url]) { - //logger(`[sampler] load ${label}..`, 'load-sample', { url }); + logger(`[sampler] load ${label}..`, 'load-sample', { url }); const timestamp = Date.now(); loadCache[url] = fetch(url) .then((res) => res.arrayBuffer()) @@ -72,7 +73,7 @@ export const loadBuffer = (url, ac, s, n = 0) => { const took = Date.now() - timestamp; const size = humanFileSize(res.byteLength); // const downSpeed = humanFileSize(res.byteLength / took); - //logger(`[sampler] load ${label}... done! loaded ${size} in ${took}ms`, 'loaded-sample', { url }); + logger(`[sampler] load ${label}... done! loaded ${size} in ${took}ms`, 'loaded-sample', { url }); const decoded = await ac.decodeAudioData(res); bufferCache[url] = decoded; return decoded; @@ -224,12 +225,12 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) { // asny stuff above took too long? if (ac.currentTime > t) { - //logger(`[sampler] still loading sound "${s}:${n}"`, 'highlight'); + logger(`[sampler] still loading sound "${s}:${n}"`, 'highlight'); // console.warn('sample still loading:', s, n); return; } if (!bufferSource) { - //logger(`[sampler] could not load "${s}:${n}"`, 'error'); + logger(`[sampler] could not load "${s}:${n}"`, 'error'); return; } bufferSource.playbackRate.value = Math.abs(speed) * bufferSource.playbackRate.value; diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 33498d95..1279000c 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -11,6 +11,7 @@ import { clamp } from './util.mjs'; import workletsUrl from './worklets.mjs?url'; import { getFilter, gainNode } from './helpers.mjs'; import { map } from 'nanostores'; +import { logger } from './logger.mjs'; export const soundMap = map(); export function registerSound(key, onTrigger, data = {}) { @@ -195,7 +196,7 @@ export const superdough = async (value, deadline, hapDuration) => { return; } if (ac.currentTime > t) { - // logger('[webaudio] skip hap: still loading', ac.currentTime - t); + logger('[webaudio] skip hap: still loading', ac.currentTime - t); return; } const chain = []; // audio nodes that will be connected to each other sequentially diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs index 6d01e37f..8b32a90c 100644 --- a/packages/webaudio/webaudio.mjs +++ b/packages/webaudio/webaudio.mjs @@ -5,9 +5,11 @@ This program is free software: you can redistribute it and/or modify it under th */ import * as strudel from '@strudel.cycles/core'; -import { superdough, getAudioContext } from 'superdough'; +import { superdough, getAudioContext, setLogger } from 'superdough'; const { Pattern, logger } = strudel; +setLogger(logger); + const hap2value = (hap) => { hap.ensureObjectValue(); return { ...hap.value, velocity: hap.context.velocity }; From 5c1d7baf72cc2e78c5300aed92486d363ac52e70 Mon Sep 17 00:00:00 2001 From: Felix Roos Date: Thu, 17 Aug 2023 11:23:17 +0200 Subject: [PATCH 13/13] superdough readme doc --- packages/superdough/README.md | 117 +++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/packages/superdough/README.md b/packages/superdough/README.md index b46ba7b5..4d670ac6 100644 --- a/packages/superdough/README.md +++ b/packages/superdough/README.md @@ -44,7 +44,122 @@ document.getElementById('play').addEventListener('click', async () => { [Open this in Codesandbox](https://codesandbox.io/s/superdough-demo-forked-sf8djh?file=/src/index.js) +## API + +### superdough(value, deadline, duration) + +```js +superdough({ s: 'bd', delay: 0.5 }, 0, 1); +``` + +- `value`: the sound properties: + - `s`: the name of the sound as loaded via `samples` or `registerSound` + - `n`: selects sample with given index + - `bank`: prefix_ that is attached to the sound, e.g. `{ s: 'bd', bank: 'RolandTR909' }` = `{ s: 'RolandTR909_bd' }` + - `gain`: gain from 0 to 1 (higher values also work but might clip) + - `velocity`: additional gain multiplier + - `cutoff`: low pass filter cutoff + - `resonance`: low pass filter resonance + - `hcutoff`: high pass filter cutoff + - `hresonance`: high pass filter resonance + - `bandf`: band pass filter cutoff + - `bandq`: band pass filter resonance + - `crush`: amplitude bit crusher using given number of bits + - `shape`: distortion effect from 0 (none) to 1 (full). might get loud! + - `pan`: stereo panning from 0 (left) to 1 (right) + - `vowel`: vowel filter. possible values: "a", "e", "i", "o", "u" + - `delay`: delay mix + - `delayfeedback`: delay feedback + - `delaytime`: delay time + - `room`: reverb mix + - `size`: reverb room size + - `orbit`: bus name for global effects `delay` and `room`. same orbits will get the same effects + - `freq`: repitches sound to given frequency in Hz + - `note`: repitches sound to given note or midi number + - `cut`: sets cut group. Sounds of same group will cut each other off + - `clip`: multiplies duration with given number + - `speed`: repitches sound by given factor + - `begin`: moves beginning of sample to given factor (between 0 and 1) + - `end`: moves end of sample to given factor (between 0 and 1) + - `attack`: seconds of attack phase + - `decay`: seconds of decay phase + - `sustain`: gain of sustain phase + - `release`: seconds of release phase +- `deadline`: seconds until the sound should play (0 = immediate) +- `duration`: seconds the sound should last. optional for one shot samples, required for synth sounds + +### registerSynthSounds() + +Loads the default waveforms `sawtooth`, `square`, `triangle` and `sine`. Use them like this: + +```js +superdough({ s:'sawtooth' }, 0, 1) +``` + +The duration needs to be set for these sounds! + +### samples(sampleMap) + +allows you to load samples from URLs. There are 3 ways to load samples + +1. sample map object +2. url of sample map json file +3. github repo + +#### sample map object + +You can pass a sample map like this: + +```js +samples({ + '_base': 'https://raw.githubusercontent.com/felixroos/samples/main/', + 'bd': 'president/president_bd.mp3', + 'sd': ['president/president_sd.mp3', 'president/president_sd2.mp3'], + 'hh': ['president/president_hh.mp3'], +}) +``` + +The `_base` property defines the root url while the others declare one or more sample paths for each sound. + +For example the full URL for `bd` would then be `https://raw.githubusercontent.com/felixroos/samples/main/president/president_bd.mp3` + +A loaded sound can then be played with `superdough({ s: 'bd' }, 0)`. + +If you declare multiple sounds, you can select them with `n`: `superdough({ s: 'sd', n: 1 }, 0)` + +The duration property is not needed for samples. + +#### loading samples from a json file + +Instead of passing an object as a sample map, you can also pass a URL to a json that contains a sample map: + +```js +samples('https://raw.githubusercontent.com/felixroos/samples/main/strudel.json') +``` + +The json file is expected to have the same format as described above. + +#### loading samples from a github repo + +Because it is common to use github for samples, there is a short way to load a sample map from github: + +```js +samples('github:tidalcycles/Dirt-Samples/master') +``` + +The format is `github://`. + +It expects a `strudel.json` file to be present at the root of the given repository, which declares the sample paths in the repo. + +The format is also expected to be the same as explained above. + +### initAudioOnFirstClick() + +Initializes audio and makes sure it is playable after the first click in the document. A click is needed because of the [Autoplay Policy](https://www.w3.org/TR/autoplay-detection/). +You can call this function when the document loads. +Then just make sure your first call of `superdough` happens after a click of something. + ## Credits - [SuperDirt](https://github.com/musikinformatik/SuperDirt) -- [WebDirt](https://github.com/dktr0/WebDirt) \ No newline at end of file +- [WebDirt](https://github.com/dktr0/WebDirt)