Last active
April 28, 2024 02:00
-
-
Save ElizabethHudnott/77388d81d0367722149447db872917de to your computer and use it in GitHub Desktop.
Phase Distortion Synthesis in wavepot
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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