The Sum of Sines in HTML5/JavaScript

So far this series has focused on sine waves. The sound of a sine wave is not very interesting but they are very important in all forms of signal processing. As discussed previously, a sine wave signal in the time-domain has only one frequency. So, every signal is made up of 0 or more sine (sinusoidal) waves. In the context of audio, this means that all sound is a composite of different sinusoidal waves. These sinusoidal waves have different frequencies, amplitudes and they can also have different phases. This applies to recorded songs, voices, music, and any other audio.

Any number of signals can be combined together to make another signal. Sine waves are combined to make different waveforms and manipulated to create rich timbres. This is one way that synthesiser work and will be addressed in a later post. Rich timbres, like those from a synthesiser or a musical instrument, can be combined to make a more complex composition like a recording of an orchestra. When sound engineers mix music they may have instruments on different tracks/channels so that they can process each recording individually (we can think of a track/channel as one signal although it could be a number of signals, eg. the left and right channels of a stereo mix). Once the engineer is happy with the mix then it is ‘mixed down’ to a single track so that it can be played on a CD, MP3, etc.

Summing Sines

To combine signals together their amplitudes are summed. The amplitude of the signal at any given time is the sum of the amplitudes of its constituent signals. Pretty simple, right? This process is the same when combining complex signal (like when a sound engineer performs a mix-down) or when combining simple sine waves. What is special about the sine waves is that every signal can be broken down into units of sinusoidal waves.

The following demonstration shows how signals are summed together to make a single wave. Sine waves are used in the demonstration to keep it simple and show the effect of combining signals, but more complex signals could have been used instead.

The red, blue and green waves are all sine waves with the gain and frequency set on their respective controls. The thick black wave plots the sum of the other 3 waves. This means that at any point in time (X-axis) the amplitude of the composite signal is the sum of the amplitude of the other 3 signals.

Pressing the play button will play the sound of the composite signal. The actual composite signal data is used for the audio buffer to prove that the signal is composed of the 3 individual frequencies, as expected.

You can adjust the frequencies and gains of the waves to see it affects the composite signal. There are 4 presets which affect the frequencies and gains of each wave. These specific presets showcase different properties of the composite signal and are discussed below.

The available frequencies of each wave fit neatly into the graph so that the buffer can be easily repeated. Note that not all frequencies would fit into the graph like this.

You can see that the default settings show a combination of 3 randomly selected waves. The resulting wave no longer looks much like a sine wave. It looks more like a wave from the visualiser of an actual audio clip. This gives you an idea of how different audio is actually just a number of different waves combined.

Preset 1

All 3 individual sine waves are at the same frequency. This means the frequency of the composite signal is also the same frequency but, since it is the sum of the other 3, it is more powerful. We can see that the frequency is the same because the combined wave follows the same sine wave shape and crosses 0 in exactly the same places and the peaks and troughs are in the same places. So summing identical signals means that the signal will have the same frequencies at the same points in times but the composite output will be an amplified signal.

Preset 2 & 3

Only 2 signals are used for these 2 presets, the other has no gain. To start with, the combination acts like preset 1 and amplifies the signal. The 2 signals become increasing out of phase until they are 180 degrees out of phase and so basically cancel each other out. They then start to come back into phase and meet again at which point this cycle repeats. Preset 2 performs this cycle once per graph and preset 3 repeats it several times.

Preset 4

This preset shows the different between the effect of low and high frequency on the combined signal. The combined signal starts with only a high-frequency sine wave (number 2) present. Number 1 and 3 sine waves are low frequencies but have no gain, and so do not effect the combined signal. Adjust either of these gains and observe the affect on the signal.

Change Log

  • Changes to the sine wave graph to multiple configurable waves
  • Modularised volume warning

Source Code (click to expand)

SumOfSines.html

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <title>Sum of Sines</title>
    <script src="Scripts/com/littleDebugger/namespacer.js"></script>
    <script src="Scripts/com/littleDebugger/daw/audioContext.js"></script>
    <script src="Scripts/com/littleDebugger/daw/volumeWarning.js"></script>
    <script src="Scripts/com/littleDebugger/daw/dsp/generator/sineWave.js"></script>
</head>

<body>
    <div id="controlsContainer">
        <div id="gains" style="display:inline-block">
            <div>
                <span>Gain 1:<input id="gain1" type="range" min="0" max="0.99" step="0.01" value="0.8"/></span>
                <span><label id="gain1label"></label></span>
            </div>
            <div>
                <span>Gain 2:<input id="gain2" type="range" min="0" max="0.99" step="0.01" value="0.7"/></span>
                <span><label id="gain2label"></label></span>
            </div>
            <div>
                <span>Gain 3:<input id="gain3" type="range" min="0" max="0.99" step="0.01" value="0.5"/></span>
                <span><label id="gain3label"></label></span>
            </div>
        </div>
        <div id="freqs" style="display:inline-block; padding-left:20px">
            <div>
                <span>Frequency 1:<input id="frequency1" type="range" min="2" max="100" step="1" value="3"></span>
                <span><label id="frequency1label"></label></span>
            </div>
            <div>
                <span>Frequency 2:<input id="frequency2" type="range" min="3" max="100" step="1" value="5"></span>
                <span><label id="frequency2label"></label></span>
            </div>
            <div>
                <span>Frequency 3:<input id="frequency3" type="range" min="4" max="100" step="1" value="10"></span>
                <span><label id="frequency3label"></label></span>
            </div>
        </div>
        <div>
            <input type="button" value="Start" id="start"/>
            <input type="button" value="Stop" id="stop"/>
            <input type="button" value="Preset1" id="preset1"/>
            <input type="button" value="Preset2" id="preset2"/>
            <input type="button" value="Preset3" id="preset3"/>
            <input type="button" value="Preset4" id="preset4"/>
        </div>
    </div>
    <canvas id="canvas" height="400" width="2000">
        Browser does not support canvas
    </canvas>
    <script src="Scripts/sumOfSines.js"></script>
</body>

</html>

Scripts/sumOfSines.js

// js file for SineGraph.html

// Reference the sineWave generator.
var sineWaveGenerator = com.littleDebugger.daw.dsp.generator.sineWave;

var volumeWarning = com.littleDebugger.daw.volumeWarning();

// Controls.
var canvas = document.getElementById("canvas");
var ctx = canvas.getContext("2d");
var gain1 = document.getElementById('gain1');
var gain2 = document.getElementById('gain2');
var gain3 = document.getElementById('gain3');
var gain1label = document.getElementById('gain1label');
var gain2label = document.getElementById('gain2label');
var gain3label = document.getElementById('gain3label');
var frequency1 = document.getElementById('frequency1');
var frequency2 = document.getElementById('frequency2');
var frequency3 = document.getElementById('frequency3');
var frequency1label = document.getElementById('frequency1label');
var frequency2label = document.getElementById('frequency2label');
var frequency3label = document.getElementById('frequency3label');

var start = document.getElementById('start');
var stop = document.getElementById('stop');

// Presets.
document.getElementById('preset1').onclick = function () {
    gain1.value = 1;
    gain2.value = 0.6;
    gain3.value = 0.3;
    frequency1.value = 10;
    frequency2.value = 10;
    frequency3.value = 10;
};

document.getElementById('preset2').onclick = function () {
    gain1.value = 0;
    gain2.value = 1;
    gain3.value = 1;
    frequency1.value = 3;
    frequency2.value = 74;
    frequency3.value = 69;
};

document.getElementById('preset3').onclick = function () {
    gain1.value = 0;
    gain2.value = 1;
    gain3.value = 1;
    frequency1.value = 3;
    frequency2.value = 85;
    frequency3.value = 84;
};

document.getElementById('preset4').onclick = function () {
    gain1.value = 0;
    gain2.value = 0.21;
    gain3.value = 0;
    frequency1.value = 3;
    frequency2.value = 41;
    frequency3.value = 4;
};

var audioCtx = com.littleDebugger.daw.getAudioContext();
var sampleRate;
var numberOfWaves = 3;
var windowsPerSecond = 42;
var noneCroppingHeight = canvas.height - 2;

// Set the grid colours.
var axisColor = "black";
var divideColor = "grey";

var audioPlaying = false;
var interval;

if (audioCtx == null) {
    // Setup when Web Audio API is not availalble.
    sampleRate = 48000;
    start.onclick = function () {
        alert("Web Audio API is not available. Please use a supported browser.");
    };
} else {
    // Setup when Web Audio API is available.
    sampleRate = audioCtx.sampleRate;
    var scriptNode = audioCtx.createScriptProcessor(8192, 1, 1);
    start.onclick = function () {

        if (audioPlaying) {
            return;
        }

        volumeWarning.eventFired();

        audioPlaying = true;

        clearTimeout(interval);
        scriptNode = audioCtx.createScriptProcessor(4096, 1, 1);
        redrawCanvas();

        var i = 0;
        scriptNode.onaudioprocess = function (audioProcessingEvent) {
            var outputBuffer = audioProcessingEvent.outputBuffer;
            var output = outputBuffer.getChannelData(0);
            for (var sample = 0; sample < outputBuffer.length; sample++) {
                output[sample] = buffer[i];
                i++;

                if (i >= bufferLength) {
                    i = 0;
                }
            }

            redrawCanvas();
        };

        scriptNode.connect(audioCtx.destination);
        console.log('Audio playing.');
    };

    // Stop audio button click event.
    stop.onclick = function () {
        if (!audioPlaying) {
            return;
        }

        audioPlaying = false;
        clearTimeout(interval);
        interval = window.setInterval(redrawCanvas, 1000 / 8);

        scriptNode.disconnect(audioCtx.destination);
        console.log('Audio stopped.');
    };
}

var bufferLength = Math.ceil(sampleRate / windowsPerSecond);
canvas.width = bufferLength;
var buffer = [bufferLength];

canvas.width = bufferLength;

// Draw a horizonal line of the canvas
// <y> The point on the Y-axis where the line should be draw.
// <color> Color of the line.
function drawHorizonalLine(y, color) {
    ctx.beginPath();
    ctx.strokeStyle = color;
    ctx.moveTo(0, y);
    ctx.lineTo(canvas.width, y);
    ctx.stroke();
}

// Draw a virtical line of the canvas
// <x> The point on the X-axis where the line should be draw.
// <color> Color of the line.
function drawVirticalLine(x, color) {
    ctx.beginPath();
    ctx.strokeStyle = color;
    ctx.moveTo(x, 0);
    ctx.lineTo(x, canvas.height);
    ctx.stroke();
}

// Draw a wave.
// <waveOffset> The offset of the wave.
// <color> Color of the wave.
var drawWave = function (waveOffset, gainValue, frequency, gainLabel, frequencyLabel, color) {
    // Label for the gain.
    gainLabel.innerHTML = (gainValue * 100).toFixed(0) + "%";
    // Label for the frequency.
    frequencyLabel.innerHTML = (frequency * windowsPerSecond).toFixed(0) + "Hz";
    ctx.strokeStyle = color;
    var sineWaveGeneratorInstance = sineWaveGenerator(waveOffset, sampleRate);

    ctx.beginPath();

    // 
    var adjustedGain = gainValue / numberOfWaves;
    for (var i = 0; i < canvas.width + 1; i++) {
        var amplitude = sineWaveGeneratorInstance.getSample(frequency * windowsPerSecond);
        var amplitudeWithGain = amplitude * adjustedGain;

        ctx.lineTo(i, canvas.height - ((amplitudeWithGain * noneCroppingHeight / 2) + (canvas.height / 2)));
    }

    ctx.stroke();
};

// Draw the summed wave.
// <waveControls> Array of objects for each wave.
// - objects have gain and frequency properties.
var drawSummedWave = function (waveControls, color) {
    ctx.strokeStyle = color;

    // TODO do not create a new instance each time.
    waveControls.forEach(function (waveControl) {
        waveControl.sineWaveGenerator = sineWaveGenerator(0, sampleRate);
    });

    ctx.beginPath();

    // So that the some of all sines can not be > 1 or < -1.
    var adjustedGain;
    for (var i = 0; i < canvas.width + 1; i++) {
        var amplitude = 0;

        waveControls.forEach(function (waveControl) {
            adjustedGain = (waveControl.gain / numberOfWaves);
            amplitude += (waveControl.sineWaveGenerator.getSample(waveControl.frequency * windowsPerSecond) * adjustedGain);
        });

        buffer[i] = amplitude;

        ctx.lineTo(i, canvas.height - ((amplitude * noneCroppingHeight / 2) + (canvas.height / 2)));
    }

    ctx.lineWidth = 4;
    ctx.stroke();
    ctx.lineWidth = 1;
};


var redrawCanvas = function () {
    // Clear the canvas.
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    // Draw grid. Will be moved out into a module.
    drawHorizonalLine(canvas.height / 4, divideColor);
    drawHorizonalLine(canvas.height / 4 * 3, divideColor);
    drawHorizonalLine(canvas.height / 2, axisColor);

    drawVirticalLine(canvas.width / 8, divideColor);
    drawVirticalLine(canvas.width / 8 * 2, divideColor);
    drawVirticalLine(canvas.width / 8 * 3, divideColor);
    drawVirticalLine(canvas.width / 8 * 5, divideColor);
    drawVirticalLine(canvas.width / 8 * 6, divideColor);
    drawVirticalLine(canvas.width / 8 * 7, divideColor);
    drawVirticalLine(canvas.width / 2, divideColor);

    drawWave(0, gain1.value, frequency1.value, gain1label, frequency1label, 'blue');
    drawWave(0, gain2.value, frequency2.value, gain2label, frequency2label, 'red');
    drawWave(0, gain3.value, frequency3.value, gain3label, frequency3label, 'green');
    drawSummedWave([{
        gain: gain1.value,
        frequency: frequency1.value
    }, {
        gain: gain2.value,
        frequency: frequency2.value
    }, {
        gain: gain3.value,
        frequency: frequency3.value
    }], "black");
};

// Update the canvas 8 times per second. Each redraw gets the value from the gain range control.
// setInterval was used rather than a change event because the change event is only fired once the change is complete.

redrawCanvas();

var gainControlContainer = document.getElementById('controlsContainer');

// This displays the page better when in a blog post Iframe.
// It will be modularised or replaced (hopefully with CSS only).
var setDimensions = function () {
    canvas.style.width = (window.innerWidth - 35) + "px";
    var height = window.innerHeight > window.innerWidth ?
        window.innerWidth :
        window.innerHeight;
    canvas.style.height = (height - gainControlContainer.clientHeight) - 35 + "px";
};

window.onresize = function () {
    setDimensions();
};

window.onresize();

// setInterval used until play event.
interval = window.setInterval(redrawCanvas, 1000 / 8);

Scripts/com/littleDebugger/namespacer.js

// Simple pattern used for namespacing in JavaScript.
// The module pattern will be used to group related functionality. 
// Modules are not yet supported in the main browswers natively.

// I do not plan to use any 3rd party libraries. 
// This may mean reinventing the wheel in some cases but I do not want anything 
// going on under the hood which I am not aware of.
// I will 'borrow' functions and snippets where required. This will be referenced.

if (typeof (com) === 'undefined') {
    com = {};
}

if (typeof (com.littleDebugger) === 'undefined') {
    com.littleDebugger = {};
}

if (typeof (com.littleDebugger.namespacer) === 'undefined') {
    com.littleDebugger.namespacer = {};
}

// Creates a namespace in the global space.
// <namespaceText> . seperated namespace to be created.
com.littleDebugger.namespacer.createNamespace = function (namespaceText) {
    var namespaces = namespaceText.split('.');
    if (typeof (window[namespaces[0]]) === 'undefined') {
        window[namespaces[0]] = {};
    }

    var currentSpace = window[namespaces[0]];

    for (i = 1; i < namespaces.length; i++) {
        var namespace = namespaces[i];
        if (typeof (currentSpace[namespace]) === 'undefined') {
            currentSpace[namespace] = {};
        }

        currentSpace = currentSpace[namespace];
    };
};

Scripts/com/littleDebugger/daw/audioContext.js

// Create namespace.
com.littleDebugger.namespacer.createNamespace("com.littleDebugger.daw");

// The is the visualiser.
com.littleDebugger.daw.getAudioContext = (function () {
    // Support Web Audio API in different supported broswers.
    // Taken from http://chimera.labs.oreilly.com/books/1234000001552/ch01.html#s01_2
    var getAudioContext = function () {
        var ContextClass = (
            window.AudioContext ||
            window.webkitAudioContext ||
            window.mozAudioContext ||
            window.oAudioContext ||
            window.msAudioContext);
        if (ContextClass) {
            var context = new ContextClass();
            console.log("Sample Rate:" + context.sampleRate);
            return context;
        } else {
            return null;
        }
    };

    return getAudioContext;
})();

Scripts/com/littleDebugger/daw/volumeWarning.js

// Create namespace.
com.littleDebugger.namespacer.createNamespace("com.littleDebugger.daw");

// The volume warning handler.
com.littleDebugger.daw.volumeWarning = function (callback) {
    var showWarning = true;

    var that = {};
    that.eventFired = function () {
        if (showWarning) {
            alert('Please set volume to an appropriate level.');
            showWarning = false;
        }

        if (typeof (callback) !== 'undefined') {
            callback();
        }
    };

    return that;
};

Scripts/com/littleDebugger/daw/dsp/generator/sineWave.js

// The sine (sinusoidal) wave generator.
com.littleDebugger.namespacer.createNamespace("com.littleDebugger.daw.dsp.generator.sineWave");

// 'Constructor'
// <offset> The offset of the initial phase in degrees. - Starting from 9 o'clock.
// <sampleRate> The sample rate of the audio player. This is required for correct frequency waves.
com.littleDebugger.daw.dsp.generator.sineWave = function(offset, sampleRate) {
    var circleRadians = 2 * Math.PI;
    var circleDegrees = 360;

    var that = {};
    var phase;

    // Reset the state of the generator so it can be reused.
    that.reset = function() {
        phase = circleRadians * (offset / circleDegrees);
    };

    // Get the next sample in the cycle.
    // <freqency> The frequency of the wave.
    that.getSample = function(frequency) {
        var nextValue = Math.sin(phase);
        phase += circleRadians * (frequency / sampleRate);
        if (phase > circleRadians) {
            phase = phase % circleRadians;
        }

        return nextValue;
    };

    that.reset();

    return that;
};

Scripts/com/littleDebugger/utility/ui/controlHelpers.js

com.littleDebugger.namespacer.createNamespace("com.littleDebugger.utility.ui");

// Hides the logic to show/hide elements on the DOM.
com.littleDebugger.utility.ui.controlWrapper = (function () {
    var hiddenClass = 'hidden';
    var that = {};

    var internalWrapControl = function(){

    }

    that.wrapControl = function (control) {
        var wrapper = {};

        var inputType = control.nodeName == "INPUT";

        // Hides a element.
        wrapper.hideControl = function () {
            control.classList.remove(hiddenClass);
        };

        // Shows a element.
        wrapper.showControl = function () {
            control.classList.add(hiddenClass);
        };

        wrapper.setDisabled = function(){
            control.disabled = true;
        }

        wrapper.setEnabled = function(){
            control.disabled = false;
        }        

        wrapper.setValue = function (newValue) {
            if (inputType) {
                control.value = newValue;
            } else {
                control.innerHTML = newValue;
            }
        }

        wrapper.value = control.value;

        control.onchange = function(){
            wrapper.value = control.value;
            wrapper.onchange();
        }

        control.onclick = function(){

            wrapper.onclick();
        }

        wrapper.onchange = function() {};
        wrapper.onclick = function() {};
        wrapper.getValue = function(){
            if (inputType) {
                return control.value;
            } else {
                return control.innerHTML;
            }
        }

        

        return wrapper;
    };

    that.getWrappedControl = function(element){
        return that.wrapControl(document.getElementById(element));
    };

    return that;
})();