diff --git a/packages/core/controls.mjs b/packages/core/controls.mjs index 6449969e..d979ff23 100644 --- a/packages/core/controls.mjs +++ b/packages/core/controls.mjs @@ -381,6 +381,19 @@ const generic_params = [ */ ['coarse'], + /** + * Allows you to set the output channels on the interface + * + * @name channels + * @synonyms ch + * + * @param {number | Pattern} channels pattern the output channels + * @example + * note("e a d b g").channels("3:4") + * + */ + ['channels', 'ch'], + ['phaserrate', 'phasr'], // superdirt only /** diff --git a/packages/superdough/superdough.mjs b/packages/superdough/superdough.mjs index ed133366..2305ab44 100644 --- a/packages/superdough/superdough.mjs +++ b/packages/superdough/superdough.mjs @@ -27,28 +27,16 @@ export function getSound(s) { export const resetLoadedSounds = () => soundMap.set({}); let audioContext; + export const getAudioContext = () => { if (!audioContext) { audioContext = new AudioContext(); + const maxChannelCount = audioContext.destination.maxChannelCount; + audioContext.destination.channelCount = maxChannelCount; } 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() { @@ -95,6 +83,39 @@ export async function initAudioOnFirstClick(options) { let delays = {}; const maxfeedback = 0.98; +let channelMerger, destinationGain; + +// input: AudioNode, channels: ?Array +export const connectToDestination = (input, channels = [0, 1]) => { + const ctx = getAudioContext(); + if (channelMerger == null) { + channelMerger = new ChannelMergerNode(ctx, { numberOfInputs: ctx.destination.channelCount }); + destinationGain = new GainNode(ctx); + channelMerger.connect(destinationGain); + destinationGain.connect(ctx.destination); + } + //This upmix can be removed if correct channel counts are set throughout the app, + // and then strudel could theoretically support surround sound audio files + const stereoMix = new StereoPannerNode(ctx); + input.connect(stereoMix); + + const splitter = new ChannelSplitterNode(ctx, { + numberOfOutputs: stereoMix.channelCount, + }); + stereoMix.connect(splitter); + channels.forEach((ch, i) => { + splitter.connect(channelMerger, i % stereoMix.channelCount, clamp(ch, 0, ctx.destination.channelCount - 1)); + }); +}; + +export const panic = () => { + if (destinationGain == null) { + return; + } + destinationGain.gain.linearRampToValueAtTime(0, getAudioContext().currentTime + 0.01); + destinationGain = null; +}; + function getDelay(orbit, delaytime, delayfeedback, t) { if (delayfeedback > maxfeedback) { //logger(`delayfeedback was clamped to ${maxfeedback} to save your ears`); @@ -104,7 +125,7 @@ function getDelay(orbit, delaytime, delayfeedback, t) { 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()); + connectToDestination(dly, [0, 1]); delays[orbit] = dly; } delays[orbit].delayTime.value !== delaytime && delays[orbit].delayTime.setValueAtTime(delaytime, t); @@ -163,7 +184,7 @@ function getReverb(orbit, duration, fade, lp, dim, ir) { if (!reverbs[orbit]) { const ac = getAudioContext(); const reverb = ac.createReverb(duration, fade, lp, dim, ir); - reverb.connect(getDestination()); + connectToDestination(reverb, [0, 1]); reverbs[orbit] = reverb; } if ( @@ -269,7 +290,7 @@ export const superdough = async (value, deadline, hapDuration) => { bpsustain = 1, bprelease = 0.01, bandq = 1, - + channels = [1, 2], //phaser phaser, phaserdepth = 0.75, @@ -301,6 +322,10 @@ export const superdough = async (value, deadline, hapDuration) => { compressorAttack, compressorRelease, } = value; + + //music programs/audio gear usually increments inputs/outputs from 1, so imitate that behavior + channels = (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1); + gain *= velocity; // legacy fix for velocity let toDisconnect = []; // audio nodes that will be disconnected when the source has ended const onended = () => { @@ -434,9 +459,9 @@ export const superdough = async (value, deadline, hapDuration) => { } // last gain - const post = gainNode(postgain); + const post = new GainNode(ac, { gain: postgain }); chain.push(post); - post.connect(getDestination()); + connectToDestination(post, channels); // delay let delaySend; diff --git a/test/__snapshots__/examples.test.mjs.snap b/test/__snapshots__/examples.test.mjs.snap index 721c7342..3d23c106 100644 --- a/test/__snapshots__/examples.test.mjs.snap +++ b/test/__snapshots__/examples.test.mjs.snap @@ -1038,6 +1038,31 @@ exports[`runs examples > example "ceil" example index 0 1`] = ` ] `; +exports[`runs examples > example "channels" example index 0 1`] = ` +[ + "[ 0/1 → 1/5 | note:e channels:[3 4] ]", + "[ 1/5 → 2/5 | note:a channels:[3 4] ]", + "[ 2/5 → 3/5 | note:d channels:[3 4] ]", + "[ 3/5 → 4/5 | note:b channels:[3 4] ]", + "[ 4/5 → 1/1 | note:g channels:[3 4] ]", + "[ 1/1 → 6/5 | note:e channels:[3 4] ]", + "[ 6/5 → 7/5 | note:a channels:[3 4] ]", + "[ 7/5 → 8/5 | note:d channels:[3 4] ]", + "[ 8/5 → 9/5 | note:b channels:[3 4] ]", + "[ 9/5 → 2/1 | note:g channels:[3 4] ]", + "[ 2/1 → 11/5 | note:e channels:[3 4] ]", + "[ 11/5 → 12/5 | note:a channels:[3 4] ]", + "[ 12/5 → 13/5 | note:d channels:[3 4] ]", + "[ 13/5 → 14/5 | note:b channels:[3 4] ]", + "[ 14/5 → 3/1 | note:g channels:[3 4] ]", + "[ 3/1 → 16/5 | note:e channels:[3 4] ]", + "[ 16/5 → 17/5 | note:a channels:[3 4] ]", + "[ 17/5 → 18/5 | note:d channels:[3 4] ]", + "[ 18/5 → 19/5 | note:b channels:[3 4] ]", + "[ 19/5 → 4/1 | note:g channels:[3 4] ]", +] +`; + exports[`runs examples > example "chooseCycles" example index 0 1`] = ` [ "[ 0/1 → 1/4 | s:bd ]", diff --git a/website/src/repl/Footer.jsx b/website/src/repl/Footer.jsx index b03f62d0..35625093 100644 --- a/website/src/repl/Footer.jsx +++ b/website/src/repl/Footer.jsx @@ -7,7 +7,7 @@ import React, { useMemo, useCallback, useLayoutEffect, useRef, useState } from ' import { Reference } from './Reference'; import { themes } from './themes.mjs'; import { useSettings, settingsMap, setActiveFooter, defaultSettings } from '../settings.mjs'; -import { getAudioContext, soundMap } from '@strudel.cycles/webaudio'; +import { getAudioContext, soundMap, connectToDestination } from '@strudel.cycles/webaudio'; import { useStore } from '@nanostores/react'; import { FilesTab } from './FilesTab'; @@ -271,7 +271,7 @@ function SoundsTab() { const onended = () => trigRef.current?.node?.disconnect(); trigRef.current = Promise.resolve(onTrigger(time, params, onended)); trigRef.current.then((ref) => { - ref?.node.connect(ctx.destination); + connectToDestination(ref?.node); }); }} >