Add wavetable oscillator with scanning, warps, and detune
This commit is contained in:
parent
b54139376e
commit
02cd79a6d9
8 changed files with 715 additions and 77 deletions
|
|
@ -87,6 +87,45 @@ export function registerControl(names, ...aliases) {
|
||||||
*/
|
*/
|
||||||
export const { s, sound } = registerControl(['s', 'n', 'gain'], 'sound');
|
export const { s, sound } = registerControl(['s', 'n', 'gain'], 'sound');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position in the wavetable of the wavetable oscillator
|
||||||
|
*
|
||||||
|
* @name wtPos
|
||||||
|
* @param {number | Pattern} position Position in the wavetable from 0 to 1
|
||||||
|
* @synonyms wavetablePosition
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export const { wtPos, wavetablePosition } = registerControl('wtPos', 'wavetablePosition');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Amount of warp (alteration of the waveform) to apply to the wavetable oscillator
|
||||||
|
*
|
||||||
|
* @name wtWarp
|
||||||
|
* @param {number | Pattern} amount Warp of the wavetable from 0 to 1
|
||||||
|
* @synonyms wavetableWarp
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export const { wtWarp, wavetableWarp } = registerControl('wtWarp', 'wavetableWarp');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Amount of warp (alteration of the waveform) to apply to the wavetable oscillator.
|
||||||
|
*
|
||||||
|
* The current options are:
|
||||||
|
* 0 = asym
|
||||||
|
* 1 = mirror
|
||||||
|
* 2 = bend+
|
||||||
|
* 3 = bend-
|
||||||
|
* 4 = bend+/-
|
||||||
|
* 5 = sync
|
||||||
|
* 6 = quantize
|
||||||
|
*
|
||||||
|
* @name wtWarpMode
|
||||||
|
* @param {number | Pattern} mode Warp mode: an integer
|
||||||
|
* @synonyms wavetableWarpMode
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export const { wtWarpMode, wavetableWarpMode } = registerControl('wtWarpMode', 'wavetableWarpMode');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define a custom webaudio node to use as a sound source.
|
* Define a custom webaudio node to use as a sound source.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import cowsay from 'cowsay';
|
import cowsay from 'cowsay';
|
||||||
import { createReadStream, existsSync } from 'fs';
|
import { createReadStream, existsSync, writeFileSync } from 'fs';
|
||||||
import { readdir } from 'fs/promises';
|
import { readdir } from 'fs/promises';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import { join, sep } from 'path';
|
import { join, sep, resolve } from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
|
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
|
|
@ -36,17 +36,19 @@ async function getFilesInDirectory(directory) {
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getBanks(directory) {
|
async function getBanks(directory, flat = false) {
|
||||||
let files = await getFilesInDirectory(directory);
|
let files = await getFilesInDirectory(directory);
|
||||||
let banks = {};
|
let banks = {};
|
||||||
directory = directory.split(sep).join('/');
|
directory = directory.split(sep).join('/');
|
||||||
files = files.map((path) => {
|
files = files.map((path) => {
|
||||||
path = path.split(sep).join('/');
|
path = path.split(sep).join('/');
|
||||||
const [bank] = path.split('/').slice(-2);
|
const subDir = path.replace(directory, '');
|
||||||
|
const subDirFlat = subDir.replaceAll('/', '_').slice(1); // remove initial underscore
|
||||||
|
const subDirFlatStem = subDirFlat.replace(/\.[^.]+$/, ''); // remove extension
|
||||||
|
let bank = flat ? subDirFlatStem : subDir.split('/')[0];
|
||||||
banks[bank] = banks[bank] || [];
|
banks[bank] = banks[bank] || [];
|
||||||
const relativeUrl = path.replace(directory, '');
|
banks[bank].push(subDir);
|
||||||
banks[bank].push(relativeUrl);
|
return subDir;
|
||||||
return relativeUrl;
|
|
||||||
});
|
});
|
||||||
banks._base = `http://localhost:5432`;
|
banks._base = `http://localhost:5432`;
|
||||||
return { banks, files };
|
return { banks, files };
|
||||||
|
|
@ -54,14 +56,25 @@ async function getBanks(directory) {
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
function getArgValue(flag) {
|
||||||
|
const i = args.indexOf(flag);
|
||||||
|
if (i !== -1) {
|
||||||
|
const nextIsFlag = args[i + 1]?.startsWith('--') ?? true;
|
||||||
|
if (nextIsFlag) return true;
|
||||||
|
return args[i + 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
const directory = process.cwd();
|
let directory = getArgValue('--dir') || process.cwd();
|
||||||
|
directory = resolve(directory);
|
||||||
|
|
||||||
if (args.includes('--json')) {
|
if (args.includes('--json')) {
|
||||||
const { banks, files } = await getBanks(directory);
|
const { banks } = await getBanks(directory, getArgValue('--flat'));
|
||||||
const json = JSON.stringify(banks);
|
const json = JSON.stringify(banks);
|
||||||
console.log(json);
|
const outFile = resolve(directory, 'strudel.json');
|
||||||
process.exit(0);
|
writeFileSync(outFile, json, 'utf8');
|
||||||
|
console.log(`Wrote json to ${outFile}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
|
@ -74,7 +87,7 @@ console.log(
|
||||||
|
|
||||||
const server = http.createServer(async (req, res) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
const { banks, files } = await getBanks(directory);
|
const { banks, files } = await getBanks(directory, getArgValue('--flat'));
|
||||||
if (req.url === '/') {
|
if (req.url === '/') {
|
||||||
res.setHeader('Content-Type', 'application/json');
|
res.setHeader('Content-Type', 'application/json');
|
||||||
return res.end(JSON.stringify(banks));
|
return res.end(JSON.stringify(banks));
|
||||||
|
|
@ -82,7 +95,7 @@ const server = http.createServer(async (req, res) => {
|
||||||
let subpath = decodeURIComponent(req.url);
|
let subpath = decodeURIComponent(req.url);
|
||||||
const filePath = join(directory, subpath.split('/').join(sep));
|
const filePath = join(directory, subpath.split('/').join(sep));
|
||||||
|
|
||||||
//console.log('GET:', filePath);
|
// console.log('GET:', filePath);
|
||||||
const isFound = existsSync(filePath);
|
const isFound = existsSync(filePath);
|
||||||
if (!isFound) {
|
if (!isFound) {
|
||||||
res.statusCode = 404;
|
res.statusCode = 404;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { getAudioContext } from './superdough.mjs';
|
import { getAudioContext } from './superdough.mjs';
|
||||||
import { clamp, nanFallback } from './util.mjs';
|
import { clamp, nanFallback, midiToFreq, noteToMidi } from './util.mjs';
|
||||||
import { getNoiseBuffer } from './noise.mjs';
|
import { getNoiseBuffer } from './noise.mjs';
|
||||||
|
|
||||||
export const noises = ['pink', 'white', 'brown', 'crackle'];
|
export const noises = ['pink', 'white', 'brown', 'crackle'];
|
||||||
|
|
@ -21,7 +21,9 @@ const getSlope = (y1, y2, x1, x2) => {
|
||||||
export function getWorklet(ac, processor, params, config) {
|
export function getWorklet(ac, processor, params, config) {
|
||||||
const node = new AudioWorkletNode(ac, processor, config);
|
const node = new AudioWorkletNode(ac, processor, config);
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
node.parameters.get(key).value = value;
|
if (value !== undefined) {
|
||||||
|
node.parameters.get(key).value = value;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
@ -307,3 +309,25 @@ export function applyFM(param, value, begin) {
|
||||||
}
|
}
|
||||||
return { stop };
|
return { stop };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getFrequencyFromValue = (value, defaultNote = 36) => {
|
||||||
|
let { note, freq } = value;
|
||||||
|
note = note || defaultNote;
|
||||||
|
if (typeof note === 'string') {
|
||||||
|
note = noteToMidi(note); // e.g. c3 => 48
|
||||||
|
}
|
||||||
|
// get frequency
|
||||||
|
if (!freq && typeof note === 'number') {
|
||||||
|
freq = midiToFreq(note); // + 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(freq);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const destroyAudioWorkletNode = (node) => {
|
||||||
|
if (node == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.disconnect();
|
||||||
|
node.parameters.get('end')?.setValueAtTime(0, 0);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,4 @@ export * from './synth.mjs';
|
||||||
export * from './zzfx.mjs';
|
export * from './zzfx.mjs';
|
||||||
export * from './logger.mjs';
|
export * from './logger.mjs';
|
||||||
export * from './dspworklet.mjs';
|
export * from './dspworklet.mjs';
|
||||||
|
export * from './wavetable.mjs';
|
||||||
|
|
|
||||||
|
|
@ -555,6 +555,7 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
||||||
tremolophase = 0,
|
tremolophase = 0,
|
||||||
tremoloshape,
|
tremoloshape,
|
||||||
s = getDefaultValue('s'),
|
s = getDefaultValue('s'),
|
||||||
|
wt,
|
||||||
bank,
|
bank,
|
||||||
source,
|
source,
|
||||||
gain = getDefaultValue('gain'),
|
gain = getDefaultValue('gain'),
|
||||||
|
|
@ -681,8 +682,13 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
||||||
let sourceNode;
|
let sourceNode;
|
||||||
if (source) {
|
if (source) {
|
||||||
sourceNode = source(t, value, hapDuration, cps);
|
sourceNode = source(t, value, hapDuration, cps);
|
||||||
} else if (getSound(s)) {
|
} else {
|
||||||
const { onTrigger } = getSound(s);
|
const soundSource = wt ?? s;
|
||||||
|
const sound = getSound(soundSource);
|
||||||
|
if (!sound) {
|
||||||
|
throw new Error(`sound ${soundSource} not found! Is it loaded?`);
|
||||||
|
}
|
||||||
|
const { onTrigger } = sound;
|
||||||
const onEnded = () => {
|
const onEnded = () => {
|
||||||
audioNodes.forEach((n) => n?.disconnect());
|
audioNodes.forEach((n) => n?.disconnect());
|
||||||
activeSoundSources.delete(chainID);
|
activeSoundSources.delete(chainID);
|
||||||
|
|
@ -693,8 +699,6 @@ export const superdough = async (value, t, hapDuration, cps = 0.5, cycle = 0.5)
|
||||||
sourceNode = soundHandle.node;
|
sourceNode = soundHandle.node;
|
||||||
activeSoundSources.set(chainID, soundHandle);
|
activeSoundSources.set(chainID, soundHandle);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
throw new Error(`sound ${s} not found! Is it loaded?`);
|
|
||||||
}
|
}
|
||||||
if (!sourceNode) {
|
if (!sourceNode) {
|
||||||
// if onTrigger does not return anything, we will just silently skip
|
// if onTrigger does not return anything, we will just silently skip
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,20 @@
|
||||||
import { clamp, midiToFreq, noteToMidi } from './util.mjs';
|
import { clamp } from './util.mjs';
|
||||||
import { registerSound, getAudioContext, soundMap, getLfo } from './superdough.mjs';
|
import { registerSound, getAudioContext, soundMap, getLfo } from './superdough.mjs';
|
||||||
import {
|
import {
|
||||||
applyFM,
|
applyFM,
|
||||||
|
destroyAudioWorkletNode,
|
||||||
gainNode,
|
gainNode,
|
||||||
getADSRValues,
|
getADSRValues,
|
||||||
|
getFrequencyFromValue,
|
||||||
getParamADSR,
|
getParamADSR,
|
||||||
getPitchEnvelope,
|
getPitchEnvelope,
|
||||||
getVibratoOscillator,
|
getVibratoOscillator,
|
||||||
webAudioTimeout,
|
|
||||||
getWorklet,
|
getWorklet,
|
||||||
noises,
|
noises,
|
||||||
|
webAudioTimeout,
|
||||||
} from './helpers.mjs';
|
} from './helpers.mjs';
|
||||||
import { getNoiseMix, getNoiseOscillator } from './noise.mjs';
|
import { getNoiseMix, getNoiseOscillator } from './noise.mjs';
|
||||||
|
|
||||||
const getFrequencyFromValue = (value, defaultNote = 36) => {
|
|
||||||
let { note, freq } = value;
|
|
||||||
note = note || defaultNote;
|
|
||||||
if (typeof note === 'string') {
|
|
||||||
note = noteToMidi(note); // e.g. c3 => 48
|
|
||||||
}
|
|
||||||
// get frequency
|
|
||||||
if (!freq && typeof note === 'number') {
|
|
||||||
freq = midiToFreq(note); // + 48);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Number(freq);
|
|
||||||
};
|
|
||||||
function destroyAudioWorkletNode(node) {
|
|
||||||
if (node == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
node.disconnect();
|
|
||||||
node.parameters.get('end')?.setValueAtTime(0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const waveforms = ['triangle', 'square', 'sawtooth', 'sine'];
|
const waveforms = ['triangle', 'square', 'sawtooth', 'sine'];
|
||||||
const waveformAliases = [
|
const waveformAliases = [
|
||||||
['tri', 'triangle'],
|
['tri', 'triangle'],
|
||||||
|
|
|
||||||
253
packages/superdough/wavetable.mjs
Normal file
253
packages/superdough/wavetable.mjs
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
import { getAudioContext, registerSound } from './index.mjs';
|
||||||
|
import { clamp, getSoundIndex, valueToMidi } from './util.mjs';
|
||||||
|
import {
|
||||||
|
destroyAudioWorkletNode,
|
||||||
|
getADSRValues,
|
||||||
|
getFrequencyFromValue,
|
||||||
|
getParamADSR,
|
||||||
|
getPitchEnvelope,
|
||||||
|
getVibratoOscillator,
|
||||||
|
getWorklet,
|
||||||
|
webAudioTimeout,
|
||||||
|
} from './helpers.mjs';
|
||||||
|
import { logger } from './logger.mjs';
|
||||||
|
|
||||||
|
const WT_MAX_MIP_LEVELS = 6;
|
||||||
|
export const WarpMode = Object.freeze({
|
||||||
|
NONE: 0,
|
||||||
|
ASYM: 1,
|
||||||
|
MIRROR: 2,
|
||||||
|
BENDP: 3,
|
||||||
|
BENDM: 4,
|
||||||
|
BENDMP: 5,
|
||||||
|
SYNC: 6,
|
||||||
|
QUANT: 7,
|
||||||
|
FOLD: 8,
|
||||||
|
PWM: 9,
|
||||||
|
ORBIT: 10,
|
||||||
|
SPIN: 11,
|
||||||
|
CHAOS: 12,
|
||||||
|
PRIMES: 13,
|
||||||
|
BINARY: 14,
|
||||||
|
BROWNIAN: 15,
|
||||||
|
RECIPROCAL: 16,
|
||||||
|
WORMHOLE: 17,
|
||||||
|
LOGISTIC: 18,
|
||||||
|
SIGMOID: 19,
|
||||||
|
FRACTAL: 20,
|
||||||
|
FLIP: 21,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadWavetableFrames(url, label, frameLen = 256) {
|
||||||
|
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 frames = new Array(numFrames);
|
||||||
|
for (let i = 0; i < numFrames; i++) {
|
||||||
|
const start = i * frameLen;
|
||||||
|
frames[i] = ch0.subarray(start, start + frameLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
// build mipmaps
|
||||||
|
const mipmaps = [frames];
|
||||||
|
let levelFrames = frames;
|
||||||
|
for (let level = 1; level < WT_MAX_MIP_LEVELS; level++) {
|
||||||
|
const prevLen = levelFrames[0].length;
|
||||||
|
if (prevLen <= 32) break;
|
||||||
|
const nextLen = prevLen >> 1;
|
||||||
|
const next = levelFrames.map((src) => {
|
||||||
|
const out = new Float32Array(nextLen);
|
||||||
|
for (let j = 0; j < nextLen; j++) {
|
||||||
|
out[j] = (src[2 * j] + src[2 * j + 1]) / 2;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
mipmaps.push(next);
|
||||||
|
levelFrames = next;
|
||||||
|
}
|
||||||
|
return { frames, mipmaps, frameLen, numFrames };
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadCache = {};
|
||||||
|
|
||||||
|
function humanFileSize(bytes, si) {
|
||||||
|
var thresh = si ? 1000 : 1024;
|
||||||
|
if (bytes < thresh) return bytes + ' B';
|
||||||
|
var units = si
|
||||||
|
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||||
|
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
|
||||||
|
var u = -1;
|
||||||
|
do {
|
||||||
|
bytes /= thresh;
|
||||||
|
++u;
|
||||||
|
} while (bytes >= thresh);
|
||||||
|
return bytes.toFixed(1) + ' ' + units[u];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTableInfo(hapValue, bank) {
|
||||||
|
const { wt, n = 0 } = hapValue;
|
||||||
|
let midi = valueToMidi(hapValue, 36);
|
||||||
|
let transpose = midi - 36; // C3 is middle C;
|
||||||
|
const index = getSoundIndex(n, bank.length);
|
||||||
|
const tableUrl = bank[index];
|
||||||
|
const label = `${wt}:${index}`;
|
||||||
|
return { transpose, tableUrl, index, midi, label };
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadBuffer = (url, ac, wt, n = 0) => {
|
||||||
|
const label = wt ? `table "${wt}:${n}"` : 'table';
|
||||||
|
url = url.replace('#', '%23');
|
||||||
|
if (!loadCache[url]) {
|
||||||
|
logger(`[wavetable] load ${label}..`, 'load-table', { url });
|
||||||
|
const timestamp = Date.now();
|
||||||
|
loadCache[url] = fetch(url)
|
||||||
|
.then((res) => res.arrayBuffer())
|
||||||
|
.then(async (res) => {
|
||||||
|
const took = Date.now() - timestamp;
|
||||||
|
const size = humanFileSize(res.byteLength);
|
||||||
|
logger(`[wavetable] load ${label}... done! loaded ${size} in ${took}ms`, 'loaded-table', { url });
|
||||||
|
const decoded = await ac.decodeAudioData(res);
|
||||||
|
return decoded;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return loadCache[url];
|
||||||
|
};
|
||||||
|
|
||||||
|
function githubPath(base, subpath = '') {
|
||||||
|
if (!base.startsWith('github:')) {
|
||||||
|
throw new Error('expected "github:" at the start of pseudoUrl');
|
||||||
|
}
|
||||||
|
let [_, path] = base.split('github:');
|
||||||
|
path = path.endsWith('/') ? path.slice(0, -1) : path;
|
||||||
|
if (path.split('/').length === 2) {
|
||||||
|
// assume main as default branch if none set
|
||||||
|
path += '/main';
|
||||||
|
}
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
if (typeof value !== 'object') {
|
||||||
|
throw new Error('wrong json format for ' + key);
|
||||||
|
}
|
||||||
|
baseUrl = value._base || baseUrl;
|
||||||
|
if (baseUrl.startsWith('github:')) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a collection of wavetables to use with `wt`
|
||||||
|
*
|
||||||
|
* @name tables
|
||||||
|
*/
|
||||||
|
export const tables = async (url, frameLen, json) => {
|
||||||
|
if (json !== undefined) return _processTables(json, url, frameLen);
|
||||||
|
if (url.startsWith('github:')) {
|
||||||
|
url = githubPath(url, 'strudel.json');
|
||||||
|
}
|
||||||
|
if (url.startsWith('local:')) {
|
||||||
|
url = `http://localhost:5432`;
|
||||||
|
}
|
||||||
|
if (typeof fetch !== 'function') {
|
||||||
|
// not a browser
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const base = url.split('/').slice(0, -1).join('/');
|
||||||
|
if (typeof fetch === 'undefined') {
|
||||||
|
// skip fetch when in node / testing
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return fetch(url)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((json) => _processTables(json, url, frameLen))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
throw new Error(`error loading "${url}"`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
async function onTriggerSynth(t, value, onended, bank, frameLen) {
|
||||||
|
let { s, n = 0, duration } = value;
|
||||||
|
const ac = getAudioContext();
|
||||||
|
let [attack, decay, sustain, release] = getADSRValues([value.attack, value.decay, value.sustain, value.release]);
|
||||||
|
let sourceDesc, holdEnd, envEnd;
|
||||||
|
let { unison = 5, spread = 0.6, detune, wtPos, wtWarp, wtWarpMode } = value;
|
||||||
|
if (typeof wtWarpMode === 'string') {
|
||||||
|
wtWarpMode = WarpMode[wtWarpMode.toUpperCase()] ?? WarpMode.NONE;
|
||||||
|
}
|
||||||
|
detune = detune ?? 0.18;
|
||||||
|
const frequency = getFrequencyFromValue(value);
|
||||||
|
const voices = clamp(unison, 1, 100);
|
||||||
|
let { tableUrl, label } = getTableInfo(value, bank);
|
||||||
|
const payload = await loadWavetableFrames(tableUrl, label, frameLen);
|
||||||
|
holdEnd = t + duration;
|
||||||
|
envEnd = holdEnd + release + 0.01;
|
||||||
|
const worklet = getWorklet(
|
||||||
|
ac,
|
||||||
|
'wavetable-oscillator-processor',
|
||||||
|
{
|
||||||
|
begin: t,
|
||||||
|
end: envEnd,
|
||||||
|
frequency,
|
||||||
|
detune,
|
||||||
|
position: wtPos,
|
||||||
|
warp: wtWarp,
|
||||||
|
warpMode: wtWarpMode,
|
||||||
|
voices,
|
||||||
|
spread,
|
||||||
|
},
|
||||||
|
{ outputChannelCount: [2] },
|
||||||
|
);
|
||||||
|
worklet.port.postMessage({ type: 'tables', payload });
|
||||||
|
sourceDesc = { source: worklet };
|
||||||
|
const { source } = sourceDesc;
|
||||||
|
if (ac.currentTime > t) {
|
||||||
|
logger(`[wavetable] still loading sound "${s}:${n}"`, 'highlight');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!source) {
|
||||||
|
logger(`[wavetable] could not load "${s}:${n}"`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let vibratoOscillator = getVibratoOscillator(source.detune, value, t);
|
||||||
|
const envGain = ac.createGain();
|
||||||
|
const node = source.connect(envGain);
|
||||||
|
getParamADSR(node.gain, attack, decay, sustain, release, 0, 1, t, holdEnd, 'linear');
|
||||||
|
getPitchEnvelope(source.detune, value, t, holdEnd);
|
||||||
|
|
||||||
|
const out = ac.createGain(); // we need a separate gain for the cutgroups because firefox...
|
||||||
|
node.connect(out);
|
||||||
|
let handle = { node: out, bufferSource: source };
|
||||||
|
let timeoutNode = webAudioTimeout(
|
||||||
|
ac,
|
||||||
|
() => {
|
||||||
|
source.disconnect();
|
||||||
|
destroyAudioWorkletNode(source);
|
||||||
|
vibratoOscillator?.stop();
|
||||||
|
node.disconnect();
|
||||||
|
out.disconnect();
|
||||||
|
onended();
|
||||||
|
},
|
||||||
|
t,
|
||||||
|
envEnd,
|
||||||
|
);
|
||||||
|
handle.stop = (time) => {
|
||||||
|
timeoutNode.stop(time);
|
||||||
|
};
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,21 @@ import OLAProcessor from './ola-processor';
|
||||||
import FFT from './fft.js';
|
import FFT from './fft.js';
|
||||||
|
|
||||||
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
|
const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
|
||||||
const _mod = (n, m) => ((n % m) + m) % m;
|
const mod = (n, m) => ((n % m) + m) % m;
|
||||||
|
const lerp = (a, b, n) => n * (b - a) + a;
|
||||||
|
const pv = (arr, n) => arr[n] ?? arr[0];
|
||||||
|
const frac = (x) => x - Math.floor(x);
|
||||||
|
const ffloor = (x) => x | 0; // fast floor for non-negative
|
||||||
|
|
||||||
|
const getUnisonDetune = (unison, detune, voiceIndex) => {
|
||||||
|
if (unison < 2) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return lerp(-detune * 0.5, detune * 0.5, voiceIndex / (unison - 1));
|
||||||
|
};
|
||||||
|
const applySemitoneDetuneToFrequency = (frequency, detune) => {
|
||||||
|
return frequency * Math.pow(2, detune / 12);
|
||||||
|
};
|
||||||
|
|
||||||
// Restrict phase to the range [0, maxPhase) via wrapping
|
// Restrict phase to the range [0, maxPhase) via wrapping
|
||||||
function wrapPhase(phase, maxPhase = 1) {
|
function wrapPhase(phase, maxPhase = 1) {
|
||||||
|
|
@ -150,7 +164,7 @@ class LFOProcessor extends AudioWorkletProcessor {
|
||||||
const blockSize = output[0].length ?? 0;
|
const blockSize = output[0].length ?? 0;
|
||||||
|
|
||||||
if (this.phase == null) {
|
if (this.phase == null) {
|
||||||
this.phase = _mod(time * frequency + phaseoffset, 1);
|
this.phase = mod(time * frequency + phaseoffset, 1);
|
||||||
}
|
}
|
||||||
const dt = frequency / sampleRate;
|
const dt = frequency / sampleRate;
|
||||||
for (let n = 0; n < blockSize; n++) {
|
for (let n = 0; n < blockSize; n++) {
|
||||||
|
|
@ -378,21 +392,6 @@ class DistortProcessor extends AudioWorkletProcessor {
|
||||||
registerProcessor('distort-processor', DistortProcessor);
|
registerProcessor('distort-processor', DistortProcessor);
|
||||||
|
|
||||||
// SUPERSAW
|
// SUPERSAW
|
||||||
function lerp(a, b, n) {
|
|
||||||
return n * (b - a) + a;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUnisonDetune(unison, detune, voiceIndex) {
|
|
||||||
if (unison < 2) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return lerp(-detune * 0.5, detune * 0.5, voiceIndex / (unison - 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
function applySemitoneDetuneToFrequency(frequency, detune) {
|
|
||||||
return frequency * Math.pow(2, detune / 12);
|
|
||||||
}
|
|
||||||
|
|
||||||
class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
|
class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -454,29 +453,31 @@ class SuperSawOscillatorProcessor extends AudioWorkletProcessor {
|
||||||
}
|
}
|
||||||
|
|
||||||
const output = outputs[0];
|
const output = outputs[0];
|
||||||
const voices = params.voices[0];
|
|
||||||
const freqspread = params.freqspread[0];
|
|
||||||
const panspread = params.panspread[0] * 0.5 + 0.5;
|
|
||||||
const gain1 = Math.sqrt(1 - panspread);
|
|
||||||
const gain2 = Math.sqrt(panspread);
|
|
||||||
|
|
||||||
for (let n = 0; n < voices; n++) {
|
for (let i = 0; i < output[0].length; i++) {
|
||||||
const isOdd = (n & 1) == 1;
|
const detune = pv(params.detune, i);
|
||||||
let gainL = gain1;
|
const voices = pv(params.voices, i);
|
||||||
let gainR = gain2;
|
const freqspread = pv(params.freqspread, i);
|
||||||
// invert right and left gain
|
const panspread = pv(params.panspread, i) * 0.5 + 0.5;
|
||||||
if (isOdd) {
|
const gain1 = Math.sqrt(1 - panspread);
|
||||||
gainL = gain2;
|
const gain2 = Math.sqrt(panspread);
|
||||||
gainR = gain1;
|
let freq = pv(params.frequency, i);
|
||||||
}
|
// Main detuning
|
||||||
for (let i = 0; i < output[0].length; i++) {
|
freq = applySemitoneDetuneToFrequency(freq, detune / 100);
|
||||||
// Main detuning
|
for (let n = 0; n < voices; n++) {
|
||||||
let freq = applySemitoneDetuneToFrequency(params.frequency[i] ?? params.frequency[0], params.detune[0] / 100);
|
const isOdd = (n & 1) == 1;
|
||||||
|
let gainL = gain1;
|
||||||
|
let gainR = gain2;
|
||||||
|
// invert right and left gain
|
||||||
|
if (isOdd) {
|
||||||
|
gainL = gain2;
|
||||||
|
gainR = gain1;
|
||||||
|
}
|
||||||
// Individual voice detuning
|
// Individual voice detuning
|
||||||
freq = applySemitoneDetuneToFrequency(freq, getUnisonDetune(voices, freqspread, n));
|
freq = applySemitoneDetuneToFrequency(freq, getUnisonDetune(voices, freqspread, n));
|
||||||
// We must wrap this here because it is passed into sawblep below which
|
// We must wrap this here because it is passed into sawblep below which
|
||||||
// has domain [0, 1]
|
// has domain [0, 1]
|
||||||
const dt = _mod(freq / sampleRate, 1);
|
const dt = mod(freq / sampleRate, 1);
|
||||||
this.phase[n] = this.phase[n] ?? Math.random();
|
this.phase[n] = this.phase[n] ?? Math.random();
|
||||||
const v = waveshapes.sawblep(this.phase[n], dt);
|
const v = waveshapes.sawblep(this.phase[n], dt);
|
||||||
|
|
||||||
|
|
@ -907,3 +908,325 @@ class ByteBeatProcessor extends AudioWorkletProcessor {
|
||||||
}
|
}
|
||||||
|
|
||||||
registerProcessor('byte-beat-processor', ByteBeatProcessor);
|
registerProcessor('byte-beat-processor', ByteBeatProcessor);
|
||||||
|
|
||||||
|
|
||||||
|
export const WarpMode = Object.freeze({
|
||||||
|
NONE: 0,
|
||||||
|
ASYM: 1,
|
||||||
|
MIRROR: 2,
|
||||||
|
BENDP: 3,
|
||||||
|
BENDM: 4,
|
||||||
|
BENDMP: 5,
|
||||||
|
SYNC: 6,
|
||||||
|
QUANT: 7,
|
||||||
|
FOLD: 8,
|
||||||
|
PWM: 9,
|
||||||
|
ORBIT: 10,
|
||||||
|
SPIN: 11,
|
||||||
|
CHAOS: 12,
|
||||||
|
PRIMES: 13,
|
||||||
|
BINARY: 14,
|
||||||
|
BROWNIAN: 15,
|
||||||
|
RECIPROCAL: 16,
|
||||||
|
WORMHOLE: 17,
|
||||||
|
LOGISTIC: 18,
|
||||||
|
SIGMOID: 19,
|
||||||
|
FRACTAL: 20,
|
||||||
|
FLIP: 21,
|
||||||
|
});
|
||||||
|
|
||||||
|
function hash32(u) {
|
||||||
|
u = u + 0x7ed55d16 + (u << 12);
|
||||||
|
u = u ^ 0xc761c23c ^ (u >>> 19);
|
||||||
|
u = u + 0x165667b1 + (u << 5);
|
||||||
|
u = (u + 0xd3a2646c) ^ (u << 9);
|
||||||
|
u = u + 0xfd7046c5 + (u << 3);
|
||||||
|
u = u ^ 0xb55a4f09 ^ (u >>> 16);
|
||||||
|
return u >>> 0;
|
||||||
|
}
|
||||||
|
const hash01 = (i) => (hash32(i) >>> 8) / 0x01000000;
|
||||||
|
|
||||||
|
function bitReverse(i, n) {
|
||||||
|
let r = 0;
|
||||||
|
for (let b = 0; b < n; b++) {
|
||||||
|
r = (r << 1) | (i & 1);
|
||||||
|
i >>>= 1;
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
function noise(x) {
|
||||||
|
const i = Math.floor(x),
|
||||||
|
f = x - i;
|
||||||
|
const a = hash01(i),
|
||||||
|
b = hash01(i + 1);
|
||||||
|
return a + (b - a) * f;
|
||||||
|
}
|
||||||
|
|
||||||
|
function brownian(x, oct = 4) {
|
||||||
|
let amp = 0.5,
|
||||||
|
sum = 0,
|
||||||
|
norm = 0,
|
||||||
|
freq = 1;
|
||||||
|
for (let o = 0; o < oct; o++) {
|
||||||
|
sum += amp * noise(x * freq);
|
||||||
|
norm += amp;
|
||||||
|
amp *= 0.5;
|
||||||
|
freq *= 2;
|
||||||
|
}
|
||||||
|
return (sum / norm) * 2 - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
class WavetableOscillatorProcessor extends AudioWorkletProcessor {
|
||||||
|
static get parameterDescriptors() {
|
||||||
|
return [
|
||||||
|
{ 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: 'position', defaultValue: 0, minValue: 0, maxValue: 1 },
|
||||||
|
{ name: 'warp', defaultValue: 0, minValue: 0, maxValue: 1 },
|
||||||
|
{ name: 'warpMode', defaultValue: 0 },
|
||||||
|
{ name: 'voices', defaultValue: 1, minValue: 1, maxValue: 32 },
|
||||||
|
{ name: 'spread', defaultValue: 0, minValue: 0, maxValue: 1 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(options) {
|
||||||
|
super(options);
|
||||||
|
this.tables = null;
|
||||||
|
this.frameLen = 0;
|
||||||
|
this.numFrames = 0;
|
||||||
|
this.phase = [];
|
||||||
|
this.syncRatio = 1;
|
||||||
|
|
||||||
|
this.port.onmessage = (e) => {
|
||||||
|
const { type, payload } = e.data || {};
|
||||||
|
if (type === 'tables') {
|
||||||
|
this.tables = payload.mipmaps;
|
||||||
|
this.frameLen = payload.frameLen;
|
||||||
|
this.numFrames = this.tables[0].length;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.lfoPhase = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
_chooseMip(dphi) {
|
||||||
|
const approxHarm = Math.min(64, 1 / Math.max(1e-6, dphi));
|
||||||
|
let level = 0;
|
||||||
|
while (level + 1 < (this.tables?.length || 1) && approxHarm < this.tables[level][0].length / 8) {
|
||||||
|
level++;
|
||||||
|
}
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
_mirror(x) {
|
||||||
|
return 1 - Math.abs(2 * x - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
_toBits(amt, min = 2, max = 12) {
|
||||||
|
const b = max + (min - max) * amt;
|
||||||
|
return { b, n: Math.round(Math.pow(2, b)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
_warpPhase(phase, amt, mode) {
|
||||||
|
switch (mode) {
|
||||||
|
case WarpMode.NONE: {
|
||||||
|
return phase;
|
||||||
|
}
|
||||||
|
case WarpMode.ASYM: {
|
||||||
|
const a = 0.01 + 0.99 * amt;
|
||||||
|
return phase < a ? (0.5 * phase) / a : 0.5 + (0.5 * (phase - a)) / (1 - a);
|
||||||
|
}
|
||||||
|
case WarpMode.MIRROR: {
|
||||||
|
// Asym, then mirror
|
||||||
|
return this._mirror(this._warpPhase(phase, amt, WarpMode.ASYM));
|
||||||
|
}
|
||||||
|
case WarpMode.BENDP: {
|
||||||
|
return Math.pow(phase, 1 + 3 * amt);
|
||||||
|
}
|
||||||
|
case WarpMode.BENDM: {
|
||||||
|
return Math.pow(phase, 1 / (1 + 3 * amt));
|
||||||
|
}
|
||||||
|
case WarpMode.BENDMP: {
|
||||||
|
return amt < 0.5 ? this._warpPhase(phase, 1 - 2 * amt, 3) : this._warpPhase(phase, 2 * amt - 1, 2);
|
||||||
|
}
|
||||||
|
case WarpMode.SYNC: {
|
||||||
|
const syncRatio = Math.pow(16, amt * amt);
|
||||||
|
return (phase * syncRatio) % 1;
|
||||||
|
}
|
||||||
|
case WarpMode.QUANT: {
|
||||||
|
const { n } = this._toBits(amt);
|
||||||
|
return ffloor(phase * n) / n;
|
||||||
|
}
|
||||||
|
case WarpMode.FOLD: {
|
||||||
|
const K = 7;
|
||||||
|
const k = 1 + Math.max(1, Math.round(K * amt));
|
||||||
|
return Math.abs(frac(k * phase) - 0.5) * 2;
|
||||||
|
}
|
||||||
|
case WarpMode.PWM: {
|
||||||
|
const w = clamp(0.5 + 0.49 * (2 * amt - 1), 0, 1);
|
||||||
|
if (phase < w) return (phase / w) * 0.5;
|
||||||
|
return 0.5 + ((phase - w) / (1 - w)) * 0.5;
|
||||||
|
}
|
||||||
|
case WarpMode.ORBIT: {
|
||||||
|
const depth = 0.5 * amt;
|
||||||
|
const n = 3;
|
||||||
|
return frac(phase + depth * Math.sin(2 * Math.PI * n * phase));
|
||||||
|
}
|
||||||
|
case WarpMode.SPIN: {
|
||||||
|
const depth = 0.5 * amt;
|
||||||
|
const { n } = this._toBits(amt, 1, 6);
|
||||||
|
return frac(phase + depth * Math.sin(2 * Math.PI * n * phase));
|
||||||
|
}
|
||||||
|
case WarpMode.CHAOS: {
|
||||||
|
const r = 3.7 + 0.3 * amt;
|
||||||
|
const logistic = r * phase * (1 - phase);
|
||||||
|
return clamp((1 - amt) * phase + amt * logistic, 0, 1);
|
||||||
|
}
|
||||||
|
case WarpMode.PRIMES: {
|
||||||
|
const isPrime = (n) => {
|
||||||
|
if (n < 2) return false;
|
||||||
|
if (n % 2 === 0) return n === 2;
|
||||||
|
for (let d = 3; d * d <= n; d += 2) if (n % d === 0) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
let { n } = this._toBits(amt, 3);
|
||||||
|
while (!isPrime(n)) n++;
|
||||||
|
return ffloor(phase * n) / n;
|
||||||
|
}
|
||||||
|
case WarpMode.BINARY: {
|
||||||
|
let { b } = this._toBits(amt, 3);
|
||||||
|
b = Math.round(b);
|
||||||
|
const n = 1 << b;
|
||||||
|
const idx = ffloor(phase * n);
|
||||||
|
const ridx = bitReverse(idx, b);
|
||||||
|
return ridx / n;
|
||||||
|
}
|
||||||
|
case WarpMode.MODULAR: {
|
||||||
|
const { n } = this._toBits(amt);
|
||||||
|
const depth = 0.5 * amt;
|
||||||
|
const jump = frac(phase * n) / n;
|
||||||
|
return frac(phase + depth * jump);
|
||||||
|
}
|
||||||
|
case WarpMode.BROWNIAN: {
|
||||||
|
const disp = 0.25 * amt * brownian(64 * phase, 4);
|
||||||
|
return frac(phase + disp);
|
||||||
|
}
|
||||||
|
case WarpMode.RECIPROCAL: {
|
||||||
|
const g = 2 + 4 * amt;
|
||||||
|
const num = phase * g;
|
||||||
|
const den = phase + (1 - phase) * g;
|
||||||
|
const y = den > 1e-12 ? num / den : 0;
|
||||||
|
return clamp(y, 0, 1);
|
||||||
|
}
|
||||||
|
case WarpMode.WORMHOLE: {
|
||||||
|
const gap = clamp(0.8 * amt, 0, 1);
|
||||||
|
const a = 0.5 * (1 - gap);
|
||||||
|
const b = 0.5 * (1 + gap);
|
||||||
|
if (phase < a) return (phase / a) * 0.5;
|
||||||
|
if (phase > b) return 0.5 * (1 + (phase - b) / (1 - b));
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
|
case WarpMode.LOGISTIC: {
|
||||||
|
let x = phase;
|
||||||
|
const r = 3.6 + 0.4 * amt;
|
||||||
|
const iters = 1 + Math.round(2 * amt);
|
||||||
|
for (let i = 0; i < iters; i++) x = r * x * (1 - x);
|
||||||
|
return clamp(x, 0, 1);
|
||||||
|
}
|
||||||
|
case WarpMode.SIGMOID: {
|
||||||
|
const k = 1 + 10 * amt;
|
||||||
|
const x = phase - 0.5;
|
||||||
|
const y = 1 / (1 + Math.exp(-k * x));
|
||||||
|
const y0 = 1 / (1 + Math.exp(0.5 * k));
|
||||||
|
const y1 = 1 / (1 + Math.exp(-0.5 * k));
|
||||||
|
return (y - y0) / (y1 - y0);
|
||||||
|
}
|
||||||
|
case WarpMode.FRACTAL: {
|
||||||
|
const d = 0.5 * Math.sin(2 * Math.PI * phase) * amt;
|
||||||
|
return frac(phase + d);
|
||||||
|
}
|
||||||
|
case WarpMode.FLIP: {
|
||||||
|
return phase;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return phase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_sampleFrame(frame, phase) {
|
||||||
|
const pos = phase * (frame.length - 1);
|
||||||
|
const i = pos | 0;
|
||||||
|
const frac = pos - i;
|
||||||
|
const a = frame[i];
|
||||||
|
const b = frame[(i + 1) % frame.length];
|
||||||
|
return a + (b - a) * frac;
|
||||||
|
}
|
||||||
|
|
||||||
|
process(_inputs, outputs, parameters) {
|
||||||
|
if (currentTime >= parameters.end[0]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (currentTime <= parameters.begin[0]) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const outL = outputs[0][0];
|
||||||
|
const outR = outputs[0][1] || outputs[0][0];
|
||||||
|
|
||||||
|
if (!this.tables) {
|
||||||
|
outL.fill(0);
|
||||||
|
if (outR !== outL) outR.set(outL);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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); //Math.sin(2 * Math.PI * this.lfoPhase);
|
||||||
|
// morph across frames
|
||||||
|
const idx = tablePos * (this.numFrames - 1);
|
||||||
|
const fIdx = idx | 0;
|
||||||
|
const frac = idx - fIdx;
|
||||||
|
const warpAmount = 0.5 * Math.sin(2 * Math.PI * this.lfoPhase) + 0.5; // pv(parameters.warp, i);
|
||||||
|
const warpMode = pv(parameters.warpMode, i);
|
||||||
|
const voices = pv(parameters.voices, i);
|
||||||
|
const gain1 = Math.sqrt(1 - spread);
|
||||||
|
const gain2 = Math.sqrt(spread);
|
||||||
|
let f = pv(parameters.frequency, i);
|
||||||
|
f = applySemitoneDetuneToFrequency(f, detune / 100); // overall detune
|
||||||
|
for (let n = 0; n < voices; n++) {
|
||||||
|
const isOdd = (n & 1) == 1;
|
||||||
|
let gainL = gain1;
|
||||||
|
let gainR = gain2;
|
||||||
|
// invert right and left gain
|
||||||
|
if (isOdd) {
|
||||||
|
gainL = gain2;
|
||||||
|
gainR = gain1;
|
||||||
|
}
|
||||||
|
let fVoice = applySemitoneDetuneToFrequency(f, getUnisonDetune(voices, detune, n)); // voice detune
|
||||||
|
const dPhase = fVoice / sampleRate;
|
||||||
|
const level = this._chooseMip(dPhase);
|
||||||
|
const bank = this.tables[level];
|
||||||
|
|
||||||
|
// warp phase then sample
|
||||||
|
this.phase[n] = this.phase[n] ?? Math.random();
|
||||||
|
let 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);
|
||||||
|
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);
|
||||||
|
this.phase[n] = wrapPhase(this.phase[n] + dPhase);
|
||||||
|
}
|
||||||
|
this.lfoPhase += 1 / sampleRate;
|
||||||
|
if (this.lfoPhase >= 1) this.lfoPhase -= 1;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor('wavetable-oscillator-processor', WavetableOscillatorProcessor);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue