Skip to content

Machines (Experimental)

Elizabeth Hudnott edited this page Jul 10, 2019 · 15 revisions

Machines are a way of adding new capabilities to the synthesizer. To create and use machines you'll need to know two things.

  1. How to use the Web Audio API. (See documentation and tutorials)
  2. The names of the internal components of a Channel object and how they're wired together.

The second requirement is a problem from a software maintainability point of view. I haven't finished developing the software yet and I can't guarantee that nothing will change, so machines are a bit of an experimental feature.

The main feature of the Web Audio API is that everything is created by linking together nodes. An audio signal flows into a typical node (imagine a wire plugged into an analogue effects unit) and the node does some processing and spits out the processed audio signal. Like the built-in synthesizer components, our machines can have parameters which are adjusted using a standardized interface, either in real-time or using the sequencer.

To create a machine we need to do three things.

  1. Create one or more Web Audio nodes and wire them together ready to do some audio processing inside our machine.
  2. Take the Web Audio graph created inside a Channel object and break apart two connected nodes and rewire the output of one to become the input to our machine and take the output of our machine and connect it as a new input into the other node.
  3. Give our machine a setParameters() method that'll be called by the channel's own setParameters() method when our machine's parameters need to changed.

Understanding the ChebyshevMachine Example

I built the Chebyshev machine because I wanted something suitable as an example to explain the machines concept but also practical and genuinely useful as an audio component because I didn't want this tutorial to use a contrived example. There's also a blank template that doesn't do anything except provide the code outline that every machine needs.

ChebyshevMachine uses a Web Audio component called a WaveShaperNode. Imagine a graph of a simple waveform with time on the x-axis and amplitude values between -1 and 1 on the y-axis. A waveshaper node uses a graph with values between -1 and 1 on the x-axis and values between -1 and 1 on the y-axis. At any instant in time the waveshaper looks at our input waveform's amplitude, finds the same value on the x-axis of the waveshaping graph and reads off the matching value from the y-axis of the waveshaping graph and places that value into the output waveform. Thus, if our waveshaping graph is a straight line between (-1,-1) and (1,1) then the output signal will be the same as the input, but if you have a waveshaping graph with a curve then you can create some interesting distortion effects.

The Chebyshev polynomials of the first kind (the kind that we're concerned with) are defined by:

T₁(x) = x

T₂(x) = 2x² - 1

Tₙ(x) = 2xTₙ₋₁(x) - Tₙ₋₂(x)

If we were to configure a WaveShaperNode to model T₂ then the output waveform would have twice the frequency of the input waveform.

Now consider:

f(x) = w₁T₁(x) + w₂T₂(x) + ... + * w₁₀T₁₀(x)

By changing these weightings we can generate an output waveform that contains any mixture of the first ten harmonics of the input waveform. This gives us a rich library of sounds we can generate even when the input signal is a simple waveform like a sine wave. A feature the different possible weightings share though is that a smooth input waveform will generate a smooth output waveform. Therefore I've also incorporated a simple overdrive and hard limiting effect into ChebyshevMachine that'll square off the top and/or bottom of the output waveform if you want to.

Here's a list of all of the parameters of ChebyshevMachine.

class ChebyshevMachine extends Machine {
	static Param = Synth.enumFromArray([
		'HARMONIC_1',	// Weighting of T1(x)
		'HARMONIC_2',	// Weighting of T2(x)
		'HARMONIC_3',	// Weighting of T3(x)
		'HARMONIC_4',	// Weighting of T4(x)
		'HARMONIC_5',	// Weighting of T5(x)
		'HARMONIC_6',	// Weighting of T6(x)
		'HARMONIC_7',	// Weighting of T7(x)
		'HARMONIC_8',	// Weighting of T8(x)
		'HARMONIC_9',	// Weighting of T9(x)
		'HARMONIC_10',	// Weighting of T10(x)
		'ODD',		// Odd harmonics are multiplied by this amount
		'EVEN',		// Even harmonics are multiplied by this amount
		'SLOPE',	// Higher harmonics are reduced when this parameter is less than one.
		'DRIVE',	// Non-negative number. Zero is no distortion.
		'OFFSET',	// Amount of offset to add as a proportion of the amount of drive.
		'ACCURACY',	// Affects the number of linear segments in the waveshaping graph.
	]);

	...

}

The ODD, EVEN and SLOPE parameters change aspects of the sound that are also modelled by other parameters. For instance, the ODD parameter lets us increase or decrease the magnitudes of all of the odd harmonics with a single parameter change rather than having to alter the first, third, fifth, seventh and ninth harmonics separately.

To model our f(x) function we have to provide our WaveShaperNode with the values of f(x) at a number of equally spaced values of x between -1 and 1. The Web Audio API will infer a waveshaping graph by performing linear interpolation between these points. We'll need to generate a few hundred points to get a good approximation to the real f(x) curve. The ChebyshevMachine.Param.ACCURACY parameter lets the user of our machine change the number sample points used so that they can trade off increased accuracy versus quicker recalculation of the waveshaping graph when a parameter is changed.

Creating ChebyshevMachine

This section will delve into the details of how I wrote the ChebyshevMachine class.

constructor(audioContext) {
	// Call the superclass constructor, passing it initial values for each of the
	// machine's parameters.
	super([
		1,	// Amount of 1st harmonic. Default to no distortion.
		0,	// Amount of 2nd harmonic. Default to no distortion.
		0,	// Amount of 3rd harmonic. Default to no distortion.
		0,	// Amount of 4th harmonic. Default to no distortion.
		0,	// Amount of 5th harmonic. Default to no distortion.
		0,	// Amount of 6th harmonic. Default to no distortion.
		0,	// Amount of 7th harmonic. Default to no distortion.
		0,	// Amount of 8th harmonic. Default to no distortion.
		0,	// Amount of 9th harmonic. Default to no distortion.
		0,	// Amount of 10th harmonic. Default to no distortion.
		1,	// Don't modify odd harmonic weightings.
		1,	// Don't modify even harmonic weightings.
		1,	// No slope. Don't modify harmonic weightings.
		0,	// No drive
		0,	// No offset
		23,	// Default accuracy (280 points)
	]);

	// Here we create the machine's internal components using the Web Audio API.
	// In this case we just need a single WaveShaperNode.
	const shaper = audioContext.createWaveShaper();
	this.shaper = shaper;

	// Connecting a node to this machine will connect that node to each of these
	// internal destinations.
	this.inputs = [shaper];

	// Connecting this machine to an external destination will connect each of these
	// internal nodes to the external destination.
	this.outputs = [shaper];
}

For the Chebyshev machine we only need one Web Audio node, the WaveShaperNode instance. The inputs property tells the superclass that connecting a signal source into the machine means connecting it as an input to the waveshaper node and the outputs property means the machine's outgoing connections are outputs from the waveshaper. You can connect multiple input signals into the machine but the signals will just be summed together to create one combined input signal. Similarly, you can have several outgoing connections but the signals sent to each one will be identical and if there is more than one item in the outputs array then the actual output signal will be the combined sum of each of the individual 'output' signals. At present machines can't distinguish between multiple inputs in a way that would permit them to process different inputs in different ways and they can't generate multiple output signals in a way that would allow external code to distinguish them separately. Although these restrictions might limit the possibilities for creating useful machines I also want to keep the API simple.

The final item we need to complete our machine is some code to make it respond to parameter change instructions.

setParameters(changes, time, callbacks) {
	const Parameter = ChebyshevMachine.Param;	// Parameter names
	const parameters = this.parameters;		// Parameter values
	const me = this; // For referring to inside callbacks.
	let dirtyCurve = false;

	for (let change of changes) {

		if (change.machine !== this) {
			continue;
		}

		const parameterNumber = change.parameterNumber;

		if (parameterNumber === Parameter.ACCURACY) {
			// Ensure this parameter has an integer value between in the right range
			let value = Math.round(parameters[parameterNumber]);
			if (value < 0) {
				value = 0;
			} else if (value > 31) {
				value = 31;
			}
			parameters[Parameter.ACCURACY] = value;
		}

		if (parameterNumber >= 0 && parameterNumber <= Parameter.ACCURACY) {
			dirtyCurve = true;
		} else {
			console.error(this.constructor.name + ': An unknown parameter name was used.');
		}
	}

	if (dirtyCurve) {
		/* Compute the weightings w₁, w₂.. w₁₀.
		   Here we use the values of the HARMONIC_1..HARMONIC_10 parameters but also
		   account for the ODD, EVEN and SLOPE parameters, for example by multiplying
		   the values of HARMONIC_1, HARMONIC_3... HARMONIC_9 by the value of ODD.
		 */

		 ... // (math omitted)

		// Compute the weighted sum of the polynomials
		const step = factors[parameters[Parameter.ACCURACY]];
		const length = arrayLength / step;
		const curve = new Float32Array(length);
		for (let i = 0; i < length; i++) {
			const index = i * step;
			let value = 0;
			for (let j = 0; j < numCoefficients; j++) {
				value += coefficients[j] * chebyshevPolynomials[j][index];
			}
			curve[i] = value;
		}

		// Normalize the curve and apply drive.

		... // (more omitted math)

		// Schedule the new curve to take effect (in the future if need be).
		callbacks.push(function () {
			me.shaper.curve = curve;
		});
	}
}
Clone this wiki locally