diff --git a/packages/core/cyclist.mjs b/packages/core/cyclist.mjs index bd2db1223..1a9ed8e74 100644 --- a/packages/core/cyclist.mjs +++ b/packages/core/cyclist.mjs @@ -11,6 +11,7 @@ export class Cyclist { constructor({ interval, onTrigger, + onPrepare, onToggle, onError, getTime, @@ -18,6 +19,7 @@ export class Cyclist { setInterval, clearInterval, beforeStart, + prepareTime = 4, }) { this.started = false; this.beforeStart = beforeStart; @@ -26,6 +28,8 @@ export class Cyclist { this.lastTick = 0; // absolute time when last tick (clock callback) happened this.lastBegin = 0; // query begin of last tick this.lastEnd = 0; // query end of last tick + this.preparedUntil = 0; + this.prepareTime = prepareTime; this.getTime = getTime; // get absolute time this.num_cycles_at_cps_change = 0; this.seconds_at_cps_change; // clock phase when cps was changed @@ -85,6 +89,33 @@ export class Cyclist { setInterval, clearInterval, ); + + onPrepare + ? (this.prepClock = createClock( + getTime, + (phase, duration, _, t) => { + try { + const start = Math.max(t, this.preparedUntil); + const end = t + this.prepareTime; + this.preparedUntil = end; + + const haps = this.pattern.queryArc(start, end, { _cps: 1 }); + + haps.forEach((hap) => { + onPrepare?.(hap); + }); + } catch (e) { + logger(`[cyclist] error: ${e.message}`); + onError?.(e); + } + }, + 1, // duration of each cycle + 1, + 0, + setInterval, + clearInterval, + )) + : null; } now() { if (!this.started) { @@ -106,16 +137,19 @@ export class Cyclist { } logger('[cyclist] start'); this.clock.start(); + this.prepClock?.start(); this.setStarted(true); } pause() { logger('[cyclist] pause'); this.clock.pause(); + this.prepClock?.pause(); this.setStarted(false); } stop() { logger('[cyclist] stop'); this.clock.stop(); + this.prepClock?.stop(); this.lastEnd = 0; this.setStarted(false); } @@ -130,6 +164,7 @@ export class Cyclist { return; } this.cps = cps; + this.preparedUntil = 0; this.num_ticks_since_cps_change = 0; } log(begin, end, haps) { diff --git a/packages/core/repl.mjs b/packages/core/repl.mjs index e703909ff..b50451d0b 100644 --- a/packages/core/repl.mjs +++ b/packages/core/repl.mjs @@ -8,6 +8,7 @@ import { register, Pattern, isPattern, silence, stack } from './pattern.mjs'; export function repl({ defaultOutput, + defaultPrepare, onEvalError, beforeEval, beforeStart, @@ -47,6 +48,7 @@ export function repl({ const schedulerOptions = { onTrigger: getTrigger({ defaultOutput, getTime }), + onPrepare: getPrepare({ defaultPrepare }), getTime, onToggle: (started) => { updateState({ started }); @@ -238,3 +240,13 @@ export const getTrigger = logger(`[cyclist] error: ${err.message}`, 'error'); } }; + +export const getPrepare = + ({ defaultPrepare }) => + async (hap) => { + try { + await defaultPrepare(hap); + } catch (err) { + logger(`[cyclist] error: ${err.message}`, 'error'); + } + }; diff --git a/packages/superdough/README.md b/packages/superdough/README.md index 0c6e4f14c..bb9787eca 100644 --- a/packages/superdough/README.md +++ b/packages/superdough/README.md @@ -92,6 +92,29 @@ superdough({ s: 'bd', delay: 0.5 }, 0, 1); - `deadline`: seconds until the sound should play (0 = immediate) - `duration`: seconds the sound should last. optional for one shot samples, required for synth sounds +### prepare(value) + +Informs superdough that a sound will be needed in the future. +If the sound is a sample that is not loaded yet, it will be fetched. +Otherwise does nothing. +`value` has a syntax identical to the one used `superdough()`. + +```js +prepare({ s: 'bd', delay: 0.5 }); + +// some time later + +superdough({ s: 'bd', delay: 0.5 }, 0, 1); +``` + +Can be awaited to ensure that a given sound is ready to play. + +```js +const sound = { s: 'hh' }; +await prepare(sound); +superdough(sound, 0, 1); +``` + ### registerSynthSounds() Loads the default waveforms `sawtooth`, `square`, `triangle` and `sine`. Use them like this: diff --git a/packages/superdough/sampler.mjs b/packages/superdough/sampler.mjs index 18d1b7797..3ca4e9465 100644 --- a/packages/superdough/sampler.mjs +++ b/packages/superdough/sampler.mjs @@ -265,19 +265,28 @@ export const samples = async (sampleMap, baseUrl = sampleMap._base || '', option processSampleMap( sampleMap, (key, bank) => - registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, bank), { - type: 'sample', - samples: bank, - baseUrl, - prebake, - tag, - }), + registerSound( + key, + (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, bank), + { + type: 'sample', + samples: bank, + baseUrl, + prebake, + tag, + }, + (hapValue) => onPrepareSample(hapValue, bank), + ), baseUrl, ); }; const cutGroups = []; +export async function onPrepareSample(hapValue, bank, resolveUrl) { + await getSampleBuffer(hapValue, bank, resolveUrl); +} + export async function onTriggerSample(t, value, onended, bank, resolveUrl) { let { s, diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index 1069d4e84..2dc4832e1 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -30,9 +30,9 @@ export function setMultiChannelOrbits(bool) { export const soundMap = map(); -export function registerSound(key, onTrigger, data = {}) { +export function registerSound(key, onTrigger, data = {}, onPrepare = () => {}) { key = key.toLowerCase().replace(/\s+/g, '_'); - soundMap.setKey(key, { onTrigger, data }); + soundMap.setKey(key, { onTrigger, data, onPrepare }); } let gainCurveFunc = (val) => val; @@ -757,3 +757,13 @@ export const superdough = async (value, t, hapDuration, cps) => { export const superdoughTrigger = (t, hap, ct, cps) => { superdough(hap, t - ct, hap.duration / cps, cps); }; + +export const prepare = async (value) => { + const { onPrepare } = getSound(value.s); + if (onPrepare) { + if (value.bank && value.s) { + value.s = `${value.bank}_${value.s}`; + } + await onPrepare(value); + } +}; diff --git a/packages/webaudio/webaudio.mjs b/packages/webaudio/webaudio.mjs index 44a683480..97dd89694 100644 --- a/packages/webaudio/webaudio.mjs +++ b/packages/webaudio/webaudio.mjs @@ -5,7 +5,7 @@ This program is free software: you can redistribute it and/or modify it under th */ import * as strudel from '@strudel/core'; -import { superdough, getAudioContext, setLogger, doughTrigger } from 'superdough'; +import { superdough, prepare, getAudioContext, setLogger, doughTrigger } from 'superdough'; const { Pattern, logger, repl } = strudel; setLogger(logger); @@ -20,6 +20,8 @@ export const webaudioOutputTrigger = (t, hap, ct, cps) => superdough(hap2value(h export const webaudioOutput = (hap, deadline, hapDuration, cps, t) => superdough(hap2value(hap), t ? `=${t}` : deadline, hapDuration); +export const webaudioPrepare = (hap) => prepare(hap2value(hap)); + Pattern.prototype.webaudio = function () { return this.onTrigger(webaudioOutputTrigger); }; @@ -28,6 +30,7 @@ export function webaudioRepl(options = {}) { options = { getTime: () => getAudioContext().currentTime, defaultOutput: webaudioOutput, + defaultPrepare: webaudioPrepare, ...options, }; return repl(options); diff --git a/website/src/repl/useReplContext.jsx b/website/src/repl/useReplContext.jsx index f88e5a6e2..3ccf41d79 100644 --- a/website/src/repl/useReplContext.jsx +++ b/website/src/repl/useReplContext.jsx @@ -10,6 +10,7 @@ import { transpiler } from '@strudel/transpiler'; import { getAudioContextCurrentTime, webaudioOutput, + webaudioPrepare, resetGlobalEffects, resetLoadedSounds, initAudioOnFirstClick, @@ -63,6 +64,7 @@ export function useReplContext() { const { isSyncEnabled, audioEngineTarget } = useSettings(); const shouldUseWebaudio = audioEngineTarget !== audioEngineTargets.osc; const defaultOutput = shouldUseWebaudio ? webaudioOutput : superdirtOutput; + const defaultPrepare = shouldUseWebaudio ? webaudioPrepare : undefined; const getTime = shouldUseWebaudio ? getAudioContextCurrentTime : getPerformanceTimeSeconds; const init = useCallback(() => { @@ -71,6 +73,7 @@ export function useReplContext() { const editor = new StrudelMirror({ sync: isSyncEnabled, defaultOutput, + defaultPrepare, getTime, setInterval, clearInterval,