Many classic analogue synthesiser lead-synth and string-ensemble sounds were based upon a modulated pulse waveform. This project demonstrates a set of techniques for recreating a Pulse Oscillator using the Web Audio API.
The new oscillator is demonstrated in the following examples:
The pulse wave is similar to a normal square waveform:
... but the "duty-cycle" or "mark-space ratio" is asymmetrical:
This creates a very distinctive sound – especially if mark-space ratio is modulated (which is what we’re going to do here).
The OscillatorNode
provided by the Web Audio API can create sine, square, triangle and sawtooth waveforms.
Let start with a normal sawtooth wave:
const audioContext = new AudioContext();
const sawtooth = new OscillatorNode(audioContext, { type: "sawtooth", frequency: 110 });
// connect to the destination
sawtooth.connect(audioContext.destination);
// start the oscillator
sawtooth.start(audioContext.currentTime);
sawtooth.stop(audioContext.currentTime + 2);
This creates a waveform that rises from -1
to +1
– with an average value of 0
Now lets add a WaveShaper
node to transform the sawtooth into a square wave:
const audioContext = new AudioContext();
// create new curve that will transform values [0:127] to -1 and [128:255] to +1
const squareCurve = new Float32Array(256);
squareCurve.fill(-1, 0, 128);
squareCurve.fill(1, 128, 256);
const sawtooth = new OscillatorNode(audioContext, { type: "sawtooth", frequency: 110 });
const squareShaper = new WaveShaperNode(audioContext, { curve: squareCurve });
// connect everything and the destination
sawtooth.connect(squareShaper);
squareShaper.connect(audioContext.destination);
// start the oscillator
sawtooth.start(audioContext.currentTime);
sawtooth.stop(audioContext.currentTime + 2);
Half of the sawtooth wave was below 0
and so it was translates to -1
and other half was above 0
and so it was translates to +1
.
To get a pulse wave however, sawtooth wave need an offset so that its duty-cycle get longer.
Adding constant signal of amplitude x
will offset any wave by this value.
There are 3 ways of creating a constant offset value using the Web Audio API:
- create an
AudioBufferSource
where theAudioBuffer
only contains the value that we want - create another
WaveShaper
node that shapes all of its input values to the desired constant value - create an
ConstantSourceNode
In this case, it is more convenient to use the WaveShaper
node:
const audioContext = new AudioContext();
const offset = 0.5;
// creating curve with constant amplitude of value
const constantCurve = (value) => {
const curve = new Float32Array(2);
curve[0] = value;
curve[1] = value;
return curve;
}
const sawtooth = new OscillatorNode(audioContext, { type: "sawtooth", frequency: 110 });
const constantShaper = new WaveShaperNode(audioContext, { curve: constantCurve(offset) });
// mixing sawtooth signal with constant on the destination thus offseting sawtooth
sawtooth.connect(constantShaper);
sawtooth.connect(audioContext.destination);
constantShaper.connect(audioContext.destination);
// start the oscillator
sawtooth.start(audioContext.currentTime);
sawtooth.stop(audioContext.currentTime + 2);
Here is pulse wave offset by 0.5
. Now its covering values from -0.5
to +1.5
By doing so sawtooth wave changed its amplitude value ratio from 50/50
negative/positive values to 25/75
:
Just like previously signal need to be transform by WaveShaper
node:
const audioContext = new AudioContext();
const offset = 0.5;
// create new curve that will transform values [0:127] to -1 and [128:255] to +1
const squareCurve = new Float32Array(256);
squareCurve.fill(-1, 0, 128);
squareCurve.fill(1, 128, 256);
// creating curve with constant amplitude of value
const constantCurve = (value) => {
const curve = new Float32Array(2);
curve[0] = value;
curve[1] = value;
return curve;
}
const sawtooth = new OscillatorNode(audioContext, { type: "sawtooth", frequency: 110 });
const squareShaper = new WaveShaperNode(audioContext, { curve: squareCurve });
const constantShaper = new WaveShaperNode(audioContext, { curve: constantCurve(offset) });
// mixing sawtooth signal with constant, transforming into square, connecting to the destination
sawtooth.connect(constantShaper);
constantShaper.connect(squareShaper);
sawtooth.connect(squareShaper);
squareShaper.connect(audioContext.destination);
// start the oscillator
sawtooth.start(audioContext.currentTime);
sawtooth.stop(audioContext.currentTime + 2);
The squareShaper
will transform this into an output where a ¼ of the output values are -1
, and the remaining ¾ are +1
.
This is cool, but the resulting sound is a bit static. It would be better to modulate pulse's width with AudioParam
.
The Web Audio API doesn’t support creation of AudioParam
object directly so we're going to be devious again – and borrow an AudioParam
from the GainNode
.
The following code adds a new createPulseOscillator
function to the AudioContext
- and exposes a width
parameter that can be modulated:
let audioContext = new (window.AudioContext ||
window.webkitAudioContext ||
function () {
throw "Your browser does not support Web Audio API";
})();
// create new curve that will flatten values [0:127] to -1 and [128:255] to 1
const squareCurve = new Float32Array(256);
squareCurve.fill(-1, 0, 128);
squareCurve.fill(1, 128, 256);
// constant signal on level 1
const constantCurve = new Float32Array(2);
constantCurve[0] = 1;
constantCurve[1] = 1;
// add a new factory method to the AudioContext object.
audioContext.createPulseOscillator = () => {
// use a normal oscillator as the basis of pulse oscillator.
const oscillator = new OscillatorNode(audioContext, { type: "sawtooth" });
// shape the output into a pulse wave.
const squareShaper = new WaveShaperNode(audioContext, { curve: squareCurve });
// pass a constant value of 1 into the widthParameter – so the "width" setting
// is duplicated to its output.
const constantShaper = new WaveShaperNode(audioContext, { curve: constantCurve });
// use a GainNode as our new "width" audio parameter.
const widthParameter = new GainNode(audioContext, { gain: 0 });
// add parameter to oscillator node as the new attribute
oscillator.width = widthParameter.gain;
// connect everything
oscillator.connect(constantShaper);
constantShaper.connect(widthParameter);
widthParameter.connect(squareShaper);
// override the oscillator's "connect" and "disconnect" method so that the
// new node's output actually comes from the squareShaper.
oscillator.connect = () => {
squareShaper.connect.apply(squareShaper, arguments);
};
oscillator.disconnect = () => {
squareShaper.disconnect.apply(squareShaper, arguments);
};
return oscillator;
};
Constant value of +1
is passed into the widthParameter
. This means that whatever we do to its "gain" parameter will be reflected onto the node’s output. We attach the "gain" parameter to the oscillator node so that it becomes part of the oscillator’s interface.
Have a play - then feel free to incorporate these techniques in your own code.
I found the following links useful for constructing this project:
- Web Audio API
- Sound-on-sound - There's a heap of good stuff in this series.
Copyright (c) 2014 Andy Harman and Pendragon Software Limited.
Released under the MIT License.