Merge pull request 'wavetable improvements' (#1608) from wt_improvements into main

Reviewed-on: https://codeberg.org/uzu/strudel/pulls/1608
This commit is contained in:
Switch Angel AKA Jade Rose
2025-09-27 21:43:09 +02:00
7 changed files with 105 additions and 70 deletions
+29 -39
View File
@@ -1,5 +1,5 @@
import { noteToMidi, valueToMidi, getSoundIndex } from './util.mjs';
import { getAudioContext, registerSound } from './index.mjs';
import { noteToMidi, valueToMidi, getSoundIndex, getCommonSampleInfo } from './util.mjs';
import { getAudioContext, registerSound, registerWaveTable } from './index.mjs';
import { getADSRValues, getParamADSR, getPitchEnvelope, getVibratoOscillator } from './helpers.mjs';
import { logger } from './logger.mjs';
@@ -22,39 +22,16 @@ function humanFileSize(bytes, si) {
return bytes.toFixed(1) + ' ' + units[u];
}
// deduces relevant info for sample loading from hap.value and sample definition
// it encapsulates the core sampler logic into a pure and synchronous function
// hapValue: Hap.value, bank: sample bank definition for sound "s" (values in strudel.json format)
export function getSampleInfo(hapValue, bank) {
const { s, n = 0, speed = 1.0 } = hapValue;
let midi = valueToMidi(hapValue, 36);
let transpose = midi - 36; // C3 is middle C;
let sampleUrl;
let index = 0;
if (Array.isArray(bank)) {
index = getSoundIndex(n, bank.length);
sampleUrl = bank[index];
} 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
index = getSoundIndex(n, bank[closest].length);
sampleUrl = bank[closest][index];
}
const label = `${s}:${index}`;
const { speed = 1.0 } = hapValue;
const { transpose, url, index, midi, label } = getCommonSampleInfo(hapValue, bank);
let playbackRate = Math.abs(speed) * Math.pow(2, transpose / 12);
return { transpose, sampleUrl, index, midi, label, playbackRate };
return { transpose, url, index, midi, label, playbackRate };
}
// takes hapValue and returns buffer + playbackRate.
export const getSampleBuffer = async (hapValue, bank, resolveUrl) => {
let { sampleUrl, label, playbackRate } = getSampleInfo(hapValue, bank);
let { url: sampleUrl, label, playbackRate } = getSampleInfo(hapValue, bank);
if (resolveUrl) {
sampleUrl = await resolveUrl(sampleUrl);
}
@@ -79,14 +56,14 @@ export const getSampleBufferSource = async (hapValue, bank, resolveUrl) => {
bufferSource.buffer = buffer;
bufferSource.playbackRate.value = playbackRate;
const { s, loopBegin = 0, loopEnd = 1, begin = 0, end = 1 } = hapValue;
const { loopBegin = 0, loopEnd = 1, begin = 0, end = 1 } = hapValue;
// "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;
const loop = s.startsWith('wt_') ? 1 : hapValue.loop;
const loop = hapValue.loop;
if (loop) {
bufferSource.loop = true;
bufferSource.loopStart = loopBegin * bufferSource.buffer.duration - offset;
@@ -267,16 +244,12 @@ export const samples = async (sampleMap, baseUrl = sampleMap._base || '', option
return samples(json, baseUrl || base, options);
}
const { prebake, tag } = options;
processSampleMap(
sampleMap,
(key, bank) =>
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, bank), {
type: 'sample',
samples: bank,
baseUrl,
prebake,
tag,
}),
(key, bank) => {
registerSampleSource(key, bank, { baseUrl, prebake, tag });
},
baseUrl,
);
};
@@ -366,3 +339,20 @@ export async function onTriggerSample(t, value, onended, bank, resolveUrl) {
return handle;
}
function registerSample(key, bank, params) {
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, bank), {
type: 'sample',
samples: bank,
...params,
});
}
export function registerSampleSource(key, bank, params) {
const isWavetable = key.startsWith('wt_');
if (isWavetable) {
registerWaveTable(key, bank, params);
} else {
registerSample(key, bank, params);
}
}
+28
View File
@@ -76,3 +76,31 @@ export function cycleToSeconds(cycle, cps) {
export function secondsToCycle(t, cps) {
return t * cps;
}
// deduces relevant info for sample loading from hap.value and sample definition
// it encapsulates the core sampler logic into a pure and synchronous function
// hapValue: Hap.value, bank: sample bank definition for sound "s" (values in strudel.json format)
export function getCommonSampleInfo(hapValue, bank) {
const { s, n = 0 } = hapValue;
let midi = valueToMidi(hapValue, 36);
let transpose = midi - 36; // C3 is middle C;
let url;
let index = 0;
if (Array.isArray(bank)) {
index = getSoundIndex(n, bank.length);
url = bank[index];
} 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
index = getSoundIndex(n, bank[closest].length);
url = bank[closest][index];
}
const label = `${s}:${index}`;
return { transpose, url, index, midi, label };
}
+17 -20
View File
@@ -1,5 +1,5 @@
import { getAudioContext, registerSound } from './index.mjs';
import { getSoundIndex, valueToMidi } from './util.mjs';
import { getCommonSampleInfo, getSoundIndex, valueToMidi } from './util.mjs';
import {
destroyAudioWorkletNode,
getADSRValues,
@@ -38,12 +38,12 @@ export const WarpMode = Object.freeze({
FLIP: 21,
});
async function loadWavetableFrames(url, label, frameLen = 256) {
async function loadWavetableFrames(url, label, frameLen = 2048) {
const ac = getAudioContext();
const buf = await loadBuffer(url, ac, label);
const ch0 = buf.getChannelData(0);
const total = ch0.length;
const numFrames = Math.floor(total / frameLen);
const numFrames = Math.max(1, Math.floor(total / frameLen));
const frames = new Array(numFrames);
for (let i = 0; i < numFrames; i++) {
const start = i * frameLen;
@@ -86,14 +86,8 @@ function humanFileSize(bytes, si) {
return bytes.toFixed(1) + ' ' + units[u];
}
export function getTableInfo(hapValue, tableUrls) {
const { s, n = 0 } = hapValue;
let midi = valueToMidi(hapValue, 36);
let transpose = midi - 36; // C3 is middle C;
const index = getSoundIndex(n, tableUrls.length);
const tableUrl = tableUrls[index];
const label = `${s}:${index}`;
return { transpose, tableUrl, index, midi, label };
export function getTableInfo(hapValue, urls) {
return getCommonSampleInfo(hapValue, urls);
}
const loadBuffer = (url, ac, label) => {
@@ -140,15 +134,18 @@ const _processTables = (json, baseUrl, frameLen) => {
baseUrl = githubPath(baseUrl, '');
}
value = value.map((v) => baseUrl + v);
registerSound(key, (t, hapValue, onended) => onTriggerSynth(t, hapValue, onended, value, frameLen), {
type: 'wavetable',
tables: value,
baseUrl,
frameLen,
});
registerWaveTable(key, value, { baseUrl, frameLen });
});
};
export function registerWaveTable(key, bank, params) {
registerSound(key, (t, hapValue, onended) => onTriggerSynth(t, hapValue, onended, bank, params?.frameLen ?? 2048), {
type: 'wavetable',
tables: bank,
...params,
});
}
/**
* Loads a collection of wavetables to use with `s`
*
@@ -179,7 +176,7 @@ export const tables = async (url, frameLen, json) => {
});
};
async function onTriggerSynth(t, value, onended, bank, frameLen) {
export async function onTriggerSynth(t, value, onended, bank, frameLen) {
const { s, n = 0, duration } = value;
const ac = getAudioContext();
const [attack, decay, sustain, release] = getADSRValues([value.attack, value.decay, value.sustain, value.release]);
@@ -188,8 +185,8 @@ async function onTriggerSynth(t, value, onended, bank, frameLen) {
wtWarpMode = WarpMode[wtWarpMode.toUpperCase()] ?? WarpMode.NONE;
}
const frequency = getFrequencyFromValue(value);
const { tableUrl, label } = getTableInfo(value, bank);
const payload = await loadWavetableFrames(tableUrl, label, frameLen);
const { url, label } = getTableInfo(value, bank);
const payload = await loadWavetableFrames(url, label, frameLen);
const holdEnd = t + duration;
const envEnd = holdEnd + release + 0.01;
const source = getWorklet(
+4 -3
View File
@@ -1049,7 +1049,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
{ name: 'begin', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY },
{ name: 'end', defaultValue: 0, min: 0, max: Number.POSITIVE_INFINITY },
{ name: 'frequency', defaultValue: 220, minValue: 0.01, maxValue: 20000 },
{ name: 'detune', defaultValue: 0 },
{ name: 'detune', defaultValue: 0.18 },
{ name: 'position', defaultValue: 0, minValue: 0, maxValue: 1 },
{ name: 'warp', defaultValue: 0, minValue: 0, maxValue: 1 },
{ name: 'warpMode', defaultValue: 0 },
@@ -1239,6 +1239,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
}
const outL = outputs[0][0];
const outR = outputs[0][1] || outputs[0][0];
const gainAdjustment = 0.3;
if (!this.tables) {
outL.fill(0);
@@ -1284,8 +1285,8 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
if (warpMode === WarpMode.FLIP && this.phase[n] < warpAmount) {
s = -s;
}
outL[i] += (s * gainL) / Math.sqrt(voices);
outR[i] += (s * gainR) / Math.sqrt(voices);
outL[i] += ((s * gainL) / Math.sqrt(voices)) * gainAdjustment;
outR[i] += ((s * gainR) / Math.sqrt(voices)) * gainAdjustment;
this.phase[n] = wrapPhase(this.phase[n] + dPhase);
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"_base": "https://raw.githubusercontent.com/tidalcycles/uzu-wavetables/main/",
"wt_digital": [
"wt_digital/wt_bad_day.wav",
"wt_digital/wt_basique.wav",
"wt_digital/wt_crickets.wav",
"wt_digital/wt_curses.wav",
"wt_digital/wt_earl_grey.wav",
"wt_digital/wt_echoes.wav",
"wt_digital/wt_glimmer.wav",
"wt_digital/wt_majick.wav",
"wt_digital/wt_meditation.wav",
"wt_digital/wt_morgana.wav",
"wt_digital/wt_red_alert.wav",
"wt_digital/wt_sad_piano.wav",
"wt_digital/wt_shook.wav",
"wt_digital/wt_sludge.wav",
"wt_digital/wt_squelch.wav",
"wt_digital/wt_summer.wav",
"wt_digital/wt_wasp.wav"
]
}
+2 -8
View File
@@ -1,4 +1,4 @@
import { registerSound, onTriggerSample } from '@strudel/webaudio';
import { registerSampleSource } from '@strudel/webaudio';
import { isAudioFile } from './files.mjs';
import { logger } from '@strudel/core';
@@ -76,13 +76,7 @@ export function registerSamplesFromDB(config = userSamplesDBConfig, onComplete =
})
.map((title) => titlePathMap.get(title));
registerSound(key, (t, hapValue, onended) => onTriggerSample(t, hapValue, onended, value), {
type: 'sample',
samples: value,
baseUrl: undefined,
prebake: false,
tag: undefined,
});
registerSampleSource(key, value, { prebake: false });
});
logger('imported sounds registered!', 'success');
+3
View File
@@ -32,6 +32,9 @@ export async function prebake() {
prebake: true,
tag: 'drum-machines',
}),
samples(`${baseNoTrailing}/uzu-wavetables.json`, undefined, {
prebake: true,
}),
samples(`${baseNoTrailing}/mridangam.json`, undefined, { prebake: true, tag: 'drum-machines' }),
samples(
{