Merge pull request #1317 from daslyfe/jade/voicemax

feat: add max polyphony feature for superdough
This commit is contained in:
Jade (Rose) Rowland 2025-04-07 22:18:03 -04:00 committed by GitHub
commit b92a28242e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 93 additions and 28 deletions

View File

@ -212,6 +212,7 @@ export function webAudioTimeout(audioContext, onComplete, startTime, stopTime) {
constantNode.onended = () => { constantNode.onended = () => {
onComplete(); onComplete();
}; };
return constantNode;
} }
const mod = (freq, range = 1, type = 'sine') => { const mod = (freq, range = 1, type = 'sine') => {
const ctx = getAudioContext(); const ctx = getAudioContext();

View File

@ -344,7 +344,9 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
}; };
let envEnd = holdEnd + release + 0.01; let envEnd = holdEnd + release + 0.01;
bufferSource.stop(envEnd); bufferSource.stop(envEnd);
const stop = (endTime, playWholeBuffer) => {}; const stop = (endTime) => {
bufferSource.stop(endTime);
};
const handle = { node: out, bufferSource, stop }; const handle = { node: out, bufferSource, stop };
// cut groups // cut groups

View File

@ -14,6 +14,11 @@ import { map } from 'nanostores';
import { logger } from './logger.mjs'; import { logger } from './logger.mjs';
import { loadBuffer } from './sampler.mjs'; import { loadBuffer } from './sampler.mjs';
export const DEFAULT_MAX_POLYPHONY = 128;
let maxPolyphony = DEFAULT_MAX_POLYPHONY;
export function setMaxPolyphony(polyphony) {
maxPolyphony = parseInt(polyphony) ?? DEFAULT_MAX_POLYPHONY;
}
export const soundMap = map(); export const soundMap = map();
export function registerSound(key, onTrigger, data = {}) { export function registerSound(key, onTrigger, data = {}) {
@ -163,7 +168,8 @@ function loadWorklets() {
// this function should be called on first user interaction (to avoid console warning) // this function should be called on first user interaction (to avoid console warning)
export async function initAudio(options = {}) { export async function initAudio(options = {}) {
const { disableWorklets = false } = options; const { disableWorklets = false, maxPolyphony } = options;
setMaxPolyphony(maxPolyphony);
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
} }
@ -375,6 +381,8 @@ export function resetGlobalEffects() {
analysersData = {}; analysersData = {};
} }
let activeSoundSources = new Map();
export const superdough = async (value, t, hapDuration) => { export const superdough = async (value, t, hapDuration) => {
const ac = getAudioContext(); const ac = getAudioContext();
t = typeof t === 'string' && t.startsWith('=') ? Number(t.slice(1)) : ac.currentTime + t; t = typeof t === 'string' && t.startsWith('=') ? Number(t.slice(1)) : ac.currentTime + t;
@ -474,14 +482,24 @@ export const superdough = async (value, t, hapDuration) => {
gain = nanFallback(gain, 1); gain = nanFallback(gain, 1);
const chainID = Math.round(Math.random() * 1000000);
// oldest audio nodes will be destroyed if maximum polyphony is exceeded
for (let i = 0; i <= activeSoundSources.size - maxPolyphony; i++) {
const ch = activeSoundSources.entries().next();
const source = ch.value[1];
const chainID = ch.value[0];
const endTime = t + 0.25;
source?.node?.gain?.linearRampToValueAtTime(0, endTime);
source?.stop?.(endTime);
activeSoundSources.delete(chainID);
}
//music programs/audio gear usually increments inputs/outputs from 1, so imitate that behavior //music programs/audio gear usually increments inputs/outputs from 1, so imitate that behavior
channels = (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1); channels = (Array.isArray(channels) ? channels : [channels]).map((ch) => ch - 1);
gain *= velocity; // velocity currently only multiplies with gain. it might do other things in the future gain *= velocity; // velocity currently only multiplies with gain. it might do other things in the future
let toDisconnect = []; // audio nodes that will be disconnected when the source has ended let audioNodes = [];
const onended = () => {
toDisconnect.forEach((n) => n?.disconnect());
};
if (bank && s) { if (bank && s) {
s = `${bank}_${s}`; s = `${bank}_${s}`;
value.s = s; value.s = s;
@ -493,10 +511,15 @@ export const superdough = async (value, t, hapDuration) => {
sourceNode = source(t, value, hapDuration); sourceNode = source(t, value, hapDuration);
} else if (getSound(s)) { } else if (getSound(s)) {
const { onTrigger } = getSound(s); const { onTrigger } = getSound(s);
const soundHandle = await onTrigger(t, value, onended); const onEnded = () => {
audioNodes.forEach((n) => n?.disconnect());
activeSoundSources.delete(chainID);
};
const soundHandle = await onTrigger(t, value, onEnded);
if (soundHandle) { if (soundHandle) {
sourceNode = soundHandle.node; sourceNode = soundHandle.node;
soundHandle.stop(t + hapDuration); activeSoundSources.set(chainID, soundHandle);
} }
} else { } else {
throw new Error(`sound ${s} not found! Is it loaded?`); throw new Error(`sound ${s} not found! Is it loaded?`);
@ -626,6 +649,7 @@ export const superdough = async (value, t, hapDuration) => {
if (delay > 0 && delaytime > 0 && delayfeedback > 0) { if (delay > 0 && delaytime > 0 && delayfeedback > 0) {
const delyNode = getDelay(orbit, delaytime, delayfeedback, t); const delyNode = getDelay(orbit, delaytime, delayfeedback, t);
delaySend = effectSend(post, delyNode, delay); delaySend = effectSend(post, delyNode, delay);
audioNodes.push(delaySend);
} }
// reverb // reverb
let reverbSend; let reverbSend;
@ -643,6 +667,7 @@ export const superdough = async (value, t, hapDuration) => {
} }
const reverbNode = getReverb(orbit, roomsize, roomfade, roomlp, roomdim, roomIR); const reverbNode = getReverb(orbit, roomsize, roomfade, roomlp, roomdim, roomIR);
reverbSend = effectSend(post, reverbNode, room); reverbSend = effectSend(post, reverbNode, room);
audioNodes.push(reverbSend);
} }
// analyser // analyser
@ -650,14 +675,12 @@ export const superdough = async (value, t, hapDuration) => {
if (analyze) { if (analyze) {
const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5)); const analyserNode = getAnalyserById(analyze, 2 ** (fft + 5));
analyserSend = effectSend(post, analyserNode, 1); analyserSend = effectSend(post, analyserNode, 1);
audioNodes.push(analyserSend);
} }
// connect chain elements together // connect chain elements together
chain.slice(1).reduce((last, current) => last.connect(current), chain[0]); chain.slice(1).reduce((last, current) => last.connect(current), chain[0]);
audioNodes = audioNodes.concat(chain);
// toDisconnect = all the node that should be disconnected in onended callback
// this is crucial for performance
toDisconnect = chain.concat([delaySend, reverbSend, analyserSend]);
}; };
export const superdoughTrigger = (t, hap, ct, cps) => { export const superdoughTrigger = (t, hap, ct, cps) => {

View File

@ -25,6 +25,10 @@ const getFrequencyFromValue = (value) => {
return Number(freq); return Number(freq);
}; };
function destroyAudioWorkletNode(node) {
node.disconnect();
node.parameters.get('end')?.setValueAtTime(0, 0);
}
const waveforms = ['triangle', 'square', 'sawtooth', 'sine']; const waveforms = ['triangle', 'square', 'sawtooth', 'sine'];
const noises = ['pink', 'white', 'brown', 'crackle']; const noises = ['pink', 'white', 'brown', 'crackle'];
@ -63,7 +67,9 @@ export function registerSynthSounds() {
stop(envEnd); stop(envEnd);
return { return {
node, node,
stop: (releaseTime) => {}, stop: (endTime) => {
stop(endTime);
},
}; };
}, },
{ type: 'synth', prebake: true }, { type: 'synth', prebake: true },
@ -110,10 +116,12 @@ export function registerSynthSounds() {
let envGain = gainNode(1); let envGain = gainNode(1);
envGain = o.connect(envGain); envGain = o.connect(envGain);
webAudioTimeout( getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 0.3 * gainAdjustment, begin, holdend, 'linear');
let timeoutNode = webAudioTimeout(
ac, ac,
() => { () => {
o.disconnect(); destroyAudioWorkletNode(o);
envGain.disconnect(); envGain.disconnect();
onended(); onended();
fm?.stop(); fm?.stop();
@ -123,11 +131,11 @@ export function registerSynthSounds() {
end, end,
); );
getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 0.3 * gainAdjustment, begin, holdend, 'linear');
return { return {
node: envGain, node: envGain,
stop: (time) => {}, stop: (time) => {
timeoutNode.stop(time);
},
}; };
}, },
{ prebake: true, type: 'synth' }, { prebake: true, type: 'synth' },
@ -169,10 +177,12 @@ export function registerSynthSounds() {
let envGain = gainNode(1); let envGain = gainNode(1);
envGain = o.connect(envGain); envGain = o.connect(envGain);
webAudioTimeout( getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear');
let timeoutNode = webAudioTimeout(
ac, ac,
() => { () => {
o.disconnect(); destroyAudioWorkletNode(o);
envGain.disconnect(); envGain.disconnect();
onended(); onended();
fm?.stop(); fm?.stop();
@ -182,11 +192,11 @@ export function registerSynthSounds() {
end, end,
); );
getParamADSR(envGain.gain, attack, decay, sustain, release, 0, 1, begin, holdend, 'linear');
return { return {
node: envGain, node: envGain,
stop: (time) => {}, stop: (time) => {
timeoutNode.stop(time);
},
}; };
}, },
{ prebake: true, type: 'synth' }, { prebake: true, type: 'synth' },
@ -229,7 +239,9 @@ export function registerSynthSounds() {
stop(envEnd); stop(envEnd);
return { return {
node, node,
stop: (releaseTime) => {}, stop: (endTime) => {
stop(endTime);
},
}; };
}, },
{ type: 'synth', prebake: true }, { type: 'synth', prebake: true },

View File

@ -710,6 +710,9 @@ class PulseOscillatorProcessor extends AudioWorkletProcessor {
} }
process(inputs, outputs, params) { process(inputs, outputs, params) {
if (this.disconnected) {
return false;
}
if (currentTime <= params.begin[0]) { if (currentTime <= params.begin[0]) {
return true; return true;
} }

View File

@ -1,10 +1,12 @@
import { defaultSettings, settingsMap, useSettings } from '../../../settings.mjs'; import { defaultSettings, settingsMap, useSettings } from '../../../settings.mjs';
import { themes } from '@strudel/codemirror'; import { themes } from '@strudel/codemirror';
import { Textbox } from '../textbox/Textbox.jsx';
import { isUdels } from '../../util.mjs'; import { isUdels } from '../../util.mjs';
import { ButtonGroup } from './Forms.jsx'; import { ButtonGroup } from './Forms.jsx';
import { AudioDeviceSelector } from './AudioDeviceSelector.jsx'; import { AudioDeviceSelector } from './AudioDeviceSelector.jsx';
import { AudioEngineTargetSelector } from './AudioEngineTargetSelector.jsx'; import { AudioEngineTargetSelector } from './AudioEngineTargetSelector.jsx';
import { confirmDialog } from '../../util.mjs'; import { confirmDialog } from '../../util.mjs';
import { DEFAULT_MAX_POLYPHONY, setMaxPolyphony } from '@strudel/webaudio';
function Checkbox({ label, value, onChange, disabled = false }) { function Checkbox({ label, value, onChange, disabled = false }) {
return ( return (
@ -53,7 +55,7 @@ function NumberSlider({ value, onChange, step = 1, ...rest }) {
); );
} }
function FormItem({ label, children }) { function FormItem({ label, children, sublabel }) {
return ( return (
<div className="grid gap-2"> <div className="grid gap-2">
<label>{label}</label> <label>{label}</label>
@ -105,6 +107,7 @@ export function SettingsTab({ started }) {
audioDeviceName, audioDeviceName,
audioEngineTarget, audioEngineTarget,
togglePanelTrigger, togglePanelTrigger,
maxPolyphony,
} = useSettings(); } = useSettings();
const shouldAlwaysSync = isUdels(); const shouldAlwaysSync = isUdels();
const canChangeAudioDevice = AudioContext.prototype.setSinkId != null; const canChangeAudioDevice = AudioContext.prototype.setSinkId != null;
@ -139,6 +142,26 @@ export function SettingsTab({ started }) {
}} }}
/> />
</FormItem> </FormItem>
<FormItem label="Maximum Polyphony">
<Textbox
min={1}
max={Infinity}
onBlur={(e) => {
let v = parseInt(e.target.value);
v = isNaN(v) ? DEFAULT_MAX_POLYPHONY : v;
setMaxPolyphony(v);
settingsMap.setKey('maxPolyphony', v);
}}
onChange={(v) => {
v = Math.max(1, parseInt(v));
settingsMap.setKey('maxPolyphony', isNaN(v) ? undefined : v);
}}
type="number"
placeholder=""
value={maxPolyphony ?? ''}
/>
</FormItem>
<FormItem label="Theme"> <FormItem label="Theme">
<SelectInput options={themeOptions} value={theme} onChange={(theme) => settingsMap.setKey('theme', theme)} /> <SelectInput options={themeOptions} value={theme} onChange={(theme) => settingsMap.setKey('theme', theme)} />
</FormItem> </FormItem>

View File

@ -36,11 +36,11 @@ import './Repl.css';
import { setInterval, clearInterval } from 'worker-timers'; import { setInterval, clearInterval } from 'worker-timers';
import { getMetadata } from '../metadata_parser'; import { getMetadata } from '../metadata_parser';
const { latestCode } = settingsMap.get(); const { latestCode, maxPolyphony } = settingsMap.get();
let modulesLoading, presets, drawContext, clearCanvas, audioReady; let modulesLoading, presets, drawContext, clearCanvas, audioReady;
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
audioReady = initAudioOnFirstClick(); audioReady = initAudioOnFirstClick({ maxPolyphony });
modulesLoading = loadModules(); modulesLoading = loadModules();
presets = prebake(); presets = prebake();
drawContext = getDrawContext(); drawContext = getDrawContext();

View File

@ -40,6 +40,7 @@ export const defaultSettings = {
audioEngineTarget: audioEngineTargets.webaudio, audioEngineTarget: audioEngineTargets.webaudio,
isButtonRowHidden: false, isButtonRowHidden: false,
isCSSAnimationDisabled: false, isCSSAnimationDisabled: false,
maxPolyphony: 128,
}; };
let search = null; let search = null;