Added examples, fixed samplerate issue on import, added to settings tab, fixed spread, added default wavetables, change default phaserand to 0

This commit is contained in:
Aria
2025-09-26 20:46:10 -07:00
parent 8b2c35b7a3
commit 97beaec25a
8 changed files with 311 additions and 38 deletions
+8 -2
View File
@@ -93,7 +93,8 @@ export const { s, sound } = registerControl(['s', 'n', 'gain'], 'sound');
* @name wtPos
* @param {number | Pattern} position Position in the wavetable from 0 to 1
* @synonyms wavetablePosition
*
* @example
* s("squelch").seg(8).note("F1").wtPos("0 0.25 0.5 0.75 1")
*/
export const { wtPos, wavetablePosition } = registerControl('wtPos', 'wavetablePosition');
@@ -103,7 +104,9 @@ export const { wtPos, wavetablePosition } = registerControl('wtPos', 'wavetableP
* @name wtWarp
* @param {number | Pattern} amount Warp of the wavetable from 0 to 1
* @synonyms wavetableWarp
*
* @example
* s("basique").seg(8).note("F1").wtWarp("0 0.25 0.5 0.75 1")
* .wtWarpMode("spin")
*/
export const { wtWarp, wavetableWarp } = registerControl('wtWarp', 'wavetableWarp');
@@ -116,6 +119,9 @@ export const { wtWarp, wavetableWarp } = registerControl('wtWarp', 'wavetableWar
* @name wtWarpMode
* @param {number | string | Pattern} mode Warp mode
* @synonyms wavetableWarpMode
* @example
* s("morgana").seg(8).note("F1").wtWarp("0 0.25 0.5 0.75 1")
* .wtWarpMode("<asym bendp spin logistic sync wormhole brownian>*2")
*
*/
export const { wtWarpMode, wavetableWarpMode } = registerControl('wtWarpMode', 'wavetableWarpMode');
+67 -23
View File
@@ -39,8 +39,7 @@ export const WarpMode = Object.freeze({
});
async function loadWavetableFrames(url, label, frameLen = 256) {
const ac = getAudioContext();
const buf = await loadBuffer(url, ac, label);
const buf = await loadBuffer(url, label);
const ch0 = buf.getChannelData(0);
const total = ch0.length;
const numFrames = Math.floor(total / frameLen);
@@ -96,7 +95,37 @@ export function getTableInfo(hapValue, tableUrls) {
return { transpose, tableUrl, index, midi, label };
}
const loadBuffer = (url, ac, label) => {
// Extract the sample rate of a .wav file
function parseWavSampleRate(arrBuf) {
const dv = new DataView(arrBuf);
// Header is "RIFF<chunk size (4 bytes)>WAVE", so 12 bytes
let p = 12;
// Look through chunks for the format header
// (they will always have an 8 byte header (id and size) followed by a payload)
while (p + 8 <= dv.byteLength) {
// Parse id
const id = String.fromCharCode(dv.getUint8(p), dv.getUint8(p + 1), dv.getUint8(p + 2), dv.getUint8(p + 3));
// Parse chunk size
const size = dv.getUint32(p + 4, true);
if (id === 'fmt ') {
// The format chunk contains the sample rate after
// 8 bytes of header, 2 bytes of format tag, 2 bytes of num channels
// (for a total of 12)
return dv.getUint32(p + 12, true);
}
// Advance to next chunk
p += 8 + size + (size & 1);
}
return null;
}
async function decodeAtNativeRate(arr) {
const sr = parseWavSampleRate(arr) || 44100;
const tempAC = new OfflineAudioContext(1, 1, sr);
return await tempAC.decodeAudioData(arr);
}
const loadBuffer = (url, label) => {
url = url.replace('#', '%23');
if (!loadCache[url]) {
logger(`[wavetable] load table ${label}..`, 'load-table', { url });
@@ -107,7 +136,7 @@ const loadBuffer = (url, ac, label) => {
const took = Date.now() - timestamp;
const size = humanFileSize(res.byteLength);
logger(`[wavetable] load table ${label}... done! loaded ${size} in ${took}ms`, 'loaded-table', { url });
const decoded = await ac.decodeAudioData(res);
const decoded = await decodeAtNativeRate(res);
return decoded;
});
}
@@ -127,25 +156,40 @@ function githubPath(base, subpath = '') {
return `https://raw.githubusercontent.com/${path}/${subpath}`;
}
const _processTables = (json, baseUrl, frameLen) => {
return Object.entries(json).forEach(([key, value]) => {
if (typeof value === 'string') {
value = [value];
const _processTables = (json, baseUrl, frameLen, options = {}) => {
baseUrl = json._base || baseUrl;
return Object.entries(json).forEach(([key, tables]) => {
if (key === '_base') return false;
if (typeof tables === 'string') {
tables = [tables];
}
if (typeof value !== 'object') {
if (typeof tables !== 'object') {
throw new Error('wrong json format for ' + key);
}
baseUrl = value._base || baseUrl;
if (baseUrl.startsWith('github:')) {
baseUrl = githubPath(baseUrl, '');
let resolvedUrl = baseUrl;
if (resolvedUrl.startsWith('github:')) {
resolvedUrl = githubPath(resolvedUrl, '');
}
tables = tables
.map((t) => resolvedUrl + t)
.filter((t) => {
if (!t.toLowerCase().endsWith('.wav')) {
logger(`[wavetable] skipping ${t} -- wavetables must be ".wav" format`);
return false;
}
return true;
});
if (tables.length) {
const { prebake, tag } = options;
registerSound(key, (t, hapValue, onended) => onTriggerSynth(t, hapValue, onended, tables, frameLen), {
type: 'wavetable',
tables,
baseUrl,
frameLen,
prebake,
tag,
});
}
value = value.map((v) => baseUrl + v);
registerSound(key, (t, hapValue, onended) => onTriggerSynth(t, hapValue, onended, value, frameLen), {
type: 'wavetable',
tables: value,
baseUrl,
frameLen,
});
});
};
@@ -154,7 +198,7 @@ const _processTables = (json, baseUrl, frameLen) => {
*
* @name tables
*/
export const tables = async (url, frameLen, json) => {
export const tables = async (url, frameLen, json, options = {}) => {
if (json !== undefined) return _processTables(json, url, frameLen);
if (url.startsWith('github:')) {
url = githubPath(url, 'strudel.json');
@@ -172,14 +216,14 @@ export const tables = async (url, frameLen, json) => {
}
return fetch(url)
.then((res) => res.json())
.then((json) => _processTables(json, url, frameLen))
.then((json) => _processTables(json, url, frameLen, options))
.catch((error) => {
console.error(error);
throw new Error(`error loading "${url}"`);
});
};
async function onTriggerSynth(t, value, onended, bank, frameLen) {
async function onTriggerSynth(t, value, onended, tables, 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,7 +232,7 @@ 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 { tableUrl, label } = getTableInfo(value, tables);
const payload = await loadWavetableFrames(tableUrl, label, frameLen);
const holdEnd = t + duration;
const envEnd = holdEnd + release + 0.01;
+12 -11
View File
@@ -988,7 +988,7 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
{ name: 'warpMode', defaultValue: 0 },
{ name: 'voices', defaultValue: 1, minValue: 1, maxValue: 32 },
{ name: 'spread', defaultValue: 0, minValue: 0, maxValue: 1 },
{ name: 'phaserand', defaultValue: 1, minValue: 0, maxValue: 1 },
{ name: 'phaserand', defaultValue: 0, minValue: 0, maxValue: 1 },
];
}
@@ -1155,10 +1155,10 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
}
_sampleFrame(frame, phase) {
const pos = phase * (frame.length - 1);
const pos = phase * frame.length;
const i = pos | 0;
const frac = pos - i;
const a = frame[i];
const a = frame[i % frame.length];
const b = frame[(i + 1) % frame.length];
return a + (b - a) * frac;
}
@@ -1181,7 +1181,6 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
for (let i = 0; i < outL.length; i++) {
const detune = pv(parameters.detune, i);
const spread = pv(parameters.spread, i) * 0.5 + 0.5;
const tablePos = pv(parameters.position, i);
const idx = tablePos * (this.numFrames - 1);
const fIdx = idx | 0;
@@ -1189,11 +1188,13 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
const warpAmount = pv(parameters.warp, i);
const warpMode = pv(parameters.warpMode, i);
const voices = pv(parameters.voices, i);
const spread = voices > 1 ? pv(parameters.spread, i) : 0;
const phaseRand = pv(parameters.phaserand, i);
const gain1 = Math.sqrt(1 - spread);
const gain2 = Math.sqrt(spread);
const gain1 = Math.sqrt(0.5 - 0.5 * spread);
const gain2 = Math.sqrt(0.5 + 0.5 * spread);
let f = pv(parameters.frequency, i);
f = applySemitoneDetuneToFrequency(f, detune / 100); // overall detune
const normalizer = 0.3 / Math.sqrt(voices);
for (let n = 0; n < voices; n++) {
const isOdd = (n & 1) == 1;
let gainL = gain1;
@@ -1206,19 +1207,19 @@ class WavetableOscillatorProcessor extends AudioWorkletProcessor {
const fVoice = applySemitoneDetuneToFrequency(f, getUnisonDetune(voices, detune, n)); // voice detune
const dPhase = fVoice / sampleRate;
const level = this._chooseMip(dPhase);
const bank = this.tables[level];
const table = this.tables[level];
// warp phase then sample
this.phase[n] = this.phase[n] ?? Math.random() * phaseRand;
const ph = this._warpPhase(this.phase[n], warpAmount, warpMode);
const s0 = this._sampleFrame(bank[fIdx], ph);
const s1 = this._sampleFrame(bank[Math.min(this.numFrames - 1, fIdx + 1)], ph);
const s0 = this._sampleFrame(table[fIdx], ph);
const s1 = this._sampleFrame(table[Math.min(this.numFrames - 1, fIdx + 1)], ph);
let s = s0 + (s1 - s0) * frac;
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 * normalizer;
outR[i] += s * gainR * normalizer;
this.phase[n] = wrapPhase(this.phase[n] + dPhase);
}
}