Skip to content

Instantly share code, notes, and snippets.

@ElizabethHudnott
Last active April 28, 2024 02:00
Show Gist options
  • Save ElizabethHudnott/77388d81d0367722149447db872917de to your computer and use it in GitHub Desktop.
Save ElizabethHudnott/77388d81d0367722149447db872917de to your computer and use it in GitHub Desktop.
Phase Distortion Synthesis in wavepot
/*
* Basic implementation of Phase Distortion Synthesis, like as was used in the Casio CZ series.
* By Elizabeth Hudnott.
*/
const TWO_PI = 2 * Math.PI;
const TIME_CONSTANTS = Math.log(2 ** (1023 / 128));
function cosine(phase) {
return -Math.cos(TWO_PI * phase);
}
// Window functions
function constantOne() {
return 1;
}
function sawWindow(phase) {
return 1 - phase;
}
function triWindow(phase) {
return phase <= 0.5 ? 2 * phase : 1 - 2 * (phase - 0.5);
}
function trapezoidWindow(phase) {
return phase <= 0.5 ? 1 : 1 - 2 * (phase - 0.5);
}
function pulseWindow(phase) {
return phase < 0.5 ? 1 - 2 * phase : 0;
}
function doubleSawWindow(phase) {
let level = 2 * phase;
level -= Math.trunc(level);
return level;
}
function logToLinear(volume) {
return volume <= 0 ? 0 : 2 ** (-8 * (1 - volume));
}
/**Multi-stage envelope.
*/
class Envelope {
constructor(times, values, offset = 0, scale = 1) {
this.times = times;
this.values = values;
this.offset = offset;
this.scale = scale;
}
getValue(time) {
const times = this.times;
const values = this.values;
const numTimes = times.length;
let startTime, startValue;
let endTime = 0;
let endValue = 0;
let endIndex = 0;
while (endIndex < numTimes) {
startTime = endTime;
endTime = startTime + times[endIndex];
startValue = endValue;
endValue = values[endIndex];
if (time <= endTime) {
break;
}
endIndex++;
}
let value;
if (time >= endTime) {
value = endValue;
} else {
const tau = (endTime - startTime) / TIME_CONSTANTS;
value = endValue + (startValue - endValue) * Math.exp((startTime - time) / tau);
}
return this.offset + this.scale * value;
}
}
const Transforms = {
/**
* @return {Array<number[]>} A transformed set of points which describe a new phase
* input-output mapping as a piecewise linear function.
*/
fadeX: function(modulation, inputX, inputY) {
const numPoints = inputX.length;
const outputX = new Array(numPoints);
for (let i = 0; i < numPoints; i++) {
outputX[i] = modulation * inputX[i] + (1 - modulation) * inputY[i];
}
return [outputX, inputY];
},
resonate: function(modulation) {
const frequency = 1 + 14 * modulation;
return [[1], [frequency]];
},
}
class PhaseFunction {
#pointsX;
#pointsY;
#transformation;
constructor(pointsX, pointsY, transformation = Transforms.fadeX, startPhase = 0) {
this.#pointsX = pointsX;
this.#pointsY = pointsY;
this.#transformation = transformation;
this.startPhase = startPhase;
}
transform(modulation) {
return this.#transformation(modulation, this.#pointsX, this.#pointsY);
}
}
class CombinedPhaseFunction {
#function1;
#function2;
constructor(function1, function2) {
this.#function1 = function1;
this.#function2 = function2;
}
get startPhase() {
return this.#function1.startPhase;
}
transform(modulation) {
let [pointsX, pointsY] = this.#function1.transform(modulation);
pointsX = pointsX.slice();
pointsY = pointsY.slice();
const [pointsX2, pointsY2] = this.#function2.transform(modulation);
const numPoints1 = pointsX.length;
const numPoints2 = pointsX2.length;
const period1 = pointsX[numPoints1 - 1];
const startPhase2 = this.#function2.startPhase;
pointsX.push(period1);
pointsY.push(startPhase2);
for (let i = 0; i < numPoints2; i++) {
pointsX.push(period1 + pointsX2[i]);
pointsY.push(startPhase2 + pointsY2[i]);
}
return [pointsX, pointsY];
}
}
/**Performs phase distortion synthesis.
*/
class PhaseDistortion {
/**Constructs a phase distortion object.
* @param {function(time: number): number} carrier The waveform that will be bent
* (conventionally a negative cosine function).
* @param {function(phase: number): number} [windowFunction] Function to use for amplitude
* modulation.
*/
constructor(carrier, phaseFunction, windowFunction = constantOne) {
// Public properties
this.frequency = 440;
this.carrier = carrier;
this.phaseFunction = phaseFunction;
this.windowFunction = windowFunction;
this.dcwPhase = 0;
this.lastTime = 0;
}
/**Resets the the oscillator's phase, resets the modulator, and let's you select a new frequency.
*/
reset(frequency, time) {
this.frequency = frequency;
this.dcwPhase = 0;
this.lastTime = time;
}
getOutput(time, modulation) {
const deltaTime = time - this.lastTime;
const [pointsX, pointsY] = this.phaseFunction.transform(modulation);
const numPoints = pointsX.length;
const period = pointsX[numPoints - 1];
const dcwPhase = (this.dcwPhase + this.frequency * deltaTime) % period;
let lastX = 0, lastY = 0;
let carrierPhase = 0;
for (let i = 0; i < numPoints; i++) {
const x = pointsX[i];
const y = pointsY[i];
if (dcwPhase <= x) {
if (x !== 0) {
carrierPhase = lastY + (dcwPhase - lastX) * (y - lastY) / (x - lastX);
}
break;
}
lastX = x;
lastY = y;
}
carrierPhase = carrierPhase + this.phaseFunction.startPhase;
carrierPhase -= Math.trunc(carrierPhase);
const windowValue = this.windowFunction(dcwPhase);
const output = windowValue * this.carrier(carrierPhase) + (1 - windowValue);
this.dcwPhase = dcwPhase;
this.lastTime = time;
return output;
}
}
const PhaseFunctions = {
// Saw
HALF_FAST: new PhaseFunction([1, 1], [0.5, 1]),
// Pulse
FINISH_EARLY: new PhaseFunction([0.5, 0.5, 1], [0, 1, 1]),
// Square
ALMOST_HOLD_START_AND_MIDDLE: new PhaseFunction([0.5, 0.5, 1, 1], [0, 0.5, 0.5, 1]),
HOLD: new PhaseFunction([1,1], [0,1]),
// Sine Pulse
WAVE_WITH_PULSE: new PhaseFunction([0.02, 1], [1, 2]),
// Saw Pulse
HALF_SLOW_AND_HOLD_AT_END: new PhaseFunction([0.5, 0.5, 1], [0.5, 1, 1]),
RESONANCE: new PhaseFunction([1], [], Transforms.resonate),
};
const attack = 0;
const noteDuration = 60 / 120;
const decay = noteDuration * 0.5;
const sustain = 0.2;
const dcwEnvelope = new Envelope([attack, decay], [1, sustain]);
dcwEnvelope.scale = 1;
/**The timbre will vary between the chosen waveform (maximum distortion) and a cosine wave (no
* distortion), according to the envelope. */
const dcwMod = time => dcwEnvelope.getValue(time);
const phaseFunction = PhaseFunctions.FINISH_EARLY;
const windowFunction = constantOne;
const oscillator = new PhaseDistortion(cosine, phaseFunction, windowFunction);
let noteNumber = 60;
let nextNoteTime = 0;
let lastNoteTime;
function roundNoteDuration(duration, frequency) {
const period = 1 / frequency;
return Math.round(duration / period) * period;
}
function dsp(time) {
if (time >= nextNoteTime) {
const maxSemitonesUp = Math.min(79 - noteNumber, 5);
const maxSemitonesDown = Math.min(noteNumber - 48, 5);
const randomRange = maxSemitonesUp + maxSemitonesDown + 1;
noteNumber += Math.trunc(Math.random() * randomRange) - maxSemitonesDown;
const frequency = 440 * 2 ** ((noteNumber - 69) / 12);
oscillator.reset(frequency, time);
lastNoteTime = time;
const noteValue = Math.trunc(Math.random() * 3) - 2;
nextNoteTime = time + roundNoteDuration(noteDuration * 2 ** noteValue, frequency);
}
const relativeTime = time - lastNoteTime;
const modulation = dcwMod(relativeTime);
return oscillator.getOutput(time, modulation);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment