Loading Symbol

JS Synthesizer Part 6: Envelopes

Synthesizer Envelope


In part five we restructured the gain staging in our synthesizer to prevent clipping. In part six, we’ll be adding an amplitude envelope with a graphic visualizer and knobs. This envelope will allow us to define attack, hold, decay, sustain, and release. It will look like the image at the top of the page when we’re done.

This tutorial is part six in a series that will show you how to create a virtual synthesizer using JavaScript and the Web Audio API. Click here to clone or download the working code examples from the GitHub repository, or click here to view the finished product that uses the concepts and code discussed in this series. To get a better idea of the complete process, checkout the overview.

Update the Synth Settings Object

First we need to add some properties to our synth object. These will be attack, hold, decay, sustain, and release. If you’re not sure what these properties do, check out this article. Our new synth object will look like this:

// Synth settings object
var synth = {
    osc: {
        on: true,
        type: 'sawtooth',
    },
    octave: 3,
    // Values in Seconds
    attack: .005,
    hold: .15,
    decay: .1,
    sustain: 40, // Gain
    release: 1,
}

These are the properties users can change on the synthesizer. Below the synth object, we need to add some large scope (but not global) accessory variables. These will assist in calculating the envelope properties, and you can change these if you feel the knobs don’t have the ranges you’re looking for.

// Multipliers to convert knob turn percentages to reasonable second values
// 'm' for Multiplier
var mAttack = 1;
var mHold = 1;
var mDecay = 1;
var mSustain = 100;
var mRelease = 1;

// Maximum values
var maxAttack = 3;
var maxHold = 2;
var maxDecay = 2;
var maxSustain = 100;
var maxRelease = 4;

Update the Voice Class

In the Voice Class, we just need to update the start() and stop() functions to use the envelope data from the synth object. To create the envelope, we’re going to use linearRampToValueAtTime(). This function takes in a gain value and a time value, and it linearly (as opposed to exponentially) changes the gain to the specified value over the specified time. Here’s the new start() function:

start() {
  var now = audioCtx.currentTime;
  this.voiceGain = audioCtx.createGain();
  this.oscillator.frequency.setValueAtTime(this.frequency, now); // value in hertz
  this.oscillator.connect(this.voiceGain);
  this.voiceGain.gain.setValueAtTime(0, now);

  // Attack
  this.voiceGain.gain.linearRampToValueAtTime(1, now + synth.attack);
  this.voiceGain.gain.setValueAtTime(1, now + synth.attack);

  // Hold
  this.voiceGain.gain.linearRampToValueAtTime(1, now + synth.attack + synth.hold);

  // Decay and sustain
  this.voiceGain.gain.linearRampToValueAtTime(synth.sustain, now + synth.attack + synth.hold + synth.decay);
  
  // console.log(gainNode.gain.value);
  this.voiceGain.connect(pregainNode); // Connecting to output
  this.oscillator.start(0);
}

As you can see, everything is relative to the variable now which is the time when the user hit the key. It ramps up the gain (gain values go from 0 to 1) to 1 over the specified attack time. Then it ramps to 1 (doesn’t change) again over the hold time. Then it ramps down to the sustain gain over the decay time. The volume will be held here until the user releases the key. When the user releases the key, the stop() function will be called. The new stop function looks like this:

stop() {
  this.voiceGain.gain.linearRampToValueAtTime(.01, audioCtx.currentTime + synth.release);
  this.oscillator.stop(audioCtx.currentTime + synth.release);
}

We’re ramping down to 0 gain over the specified release time, then we stop the oscillator completely.

Creating Knobs

In the next code section, we’re going to use Precision Inputs to create Fruity Loops style knobs and capture user input. On top of that, we’re going to use the KnobInput library. It’s really just a single class called KnobInput that contains a bunch of useful functions that can be called on each knob. Each knob is instantiated as an instance of KnobInput thereby getting all the functionality it offers. I got the KnobInput class and markup from the code in this Codepen and saved it as a separate JS dependency in this project for organization. It would be better to use gulp to concatenate, minify, and obfuscate all these JavaScript files into one, but that’s bit outside the scope of this project.

You will see that index.html in the project files for part six already includes the necessary JavaScript dependencies – precision-inputs.js and knob-input.js. They’re required to the create the knobs. First we’ll put the necessary markup in index.html to give our JavaScript something to work with. Notice that the knobs are included as <svg> , <circle>, and <path> elements, and not heavily styled with CSS.

<div class="section envelope">
  <div>
    <div style="display: none;" class="my-fl-knob"></div>
    <svg class="defs">
      <defs>
        <radialGradient id="grad-dial-soft-shadow" cx="0.5" cy="0.5" r="0.5">
          <stop offset="85%" stop-color="#242a2e" stop-opacity="0.4"></stop>
          <stop offset="100%" stop-color="#242a2e" stop-opacity="0"></stop>
        </radialGradient>
        <linearGradient id="grad-dial-base" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stop-color="#52595f"></stop>
          <stop offset="100%" stop-color="#2b3238"></stop>
        </linearGradient>
        <linearGradient id="grad-dial-highlight" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stop-color="#70777d" stop-opacity="1"></stop>
          <stop offset="40%" stop-color="#70777d" stop-opacity="0"></stop>
          <stop offset="55%" stop-color="#70777d" stop-opacity="0"></stop>
          <stop offset="100%" stop-color="#70777d" stop-opacity="0.3"></stop>
        </linearGradient>
        <filter id="glow">
          <feGaussianBlur in="SourceGraphic" result="blur" stdDeviation="2"></feGaussianBlur>
          <feComposite in="blur" in2="SourceGraphic" operator="over"></feComposite>
        </filter>
      </defs>
    </svg>
    <div id="envelope" class="fls-envelope">
      <p class="label text-center">Envelope</strong></p>
      <div class="fls-e_visualizer">
        <svg class="envelope-visualizer" viewBox="0 0 600 100" preserveAspectRatio="xMinYMid slice">
          <path class="envelope-shape" d="M0,100L0,0" fill="transparent" stroke="#4eccff" stroke-width="2"></path>
          <circle class="delay" cx="0" cy="100" r="6" fill="#284554" stroke="#4eccff" stroke-width="2"></circle>
          <circle class="attack" cx="0" cy="0" r="6" fill="#284554" stroke="#4eccff" stroke-width="2"></circle>
          <circle class="hold" cx="0" cy="0" r="6" fill="#284554" stroke="#4eccff" stroke-width="2"></circle>
          <circle class="decay" cx="0" cy="100" r="6" fill="#284554" stroke="#4eccff" stroke-width="2"></circle>
          <circle class="release" cx="0" cy="100" r="6" fill="#284554" stroke="#4eccff" stroke-width="2"></circle>
        </svg>
      </div>
      <div class="fls-e_controls">
        <div class="fls-e_control">
          <div class="knob-input fls-e_knob envelope-knob attack">
            <svg class="knob-input_visual" viewBox="0 0 40 40">
              <circle class="focus-indicator" cx="20" cy="20" r="18" fill="#4eccff" filter="url(#glow)"></circle>
              <circle class="indicator-ring-bg" cx="20" cy="20" r="18" fill="#353b3f" stroke="#23292d"></circle>
              <path class="indicator-ring" d="M20,20Z" fill="#4eccff"></path>
              <g class="dial">
                <circle cx="20" cy="20" r="16" fill="url(#grad-dial-soft-shadow)"></circle>
                <ellipse cx="20" cy="22" rx="14" ry="14.5" fill="#242a2e" opacity="0.15"></ellipse>
                <circle cx="20" cy="20" r="14" fill="url(#grad-dial-base)" stroke="#242a2e" stroke-width="1.5"></circle>
                <circle cx="20" cy="20" r="13" fill="transparent" stroke="url(#grad-dial-highlight)" stroke-width="1.5"></circle>
                <circle class="dial-highlight" cx="20" cy="20" r="14" fill="#ffffff"></circle>
                <circle class="indicator-dot" cx="20" cy="30" r="1.5" fill="#4eccff"></circle>
              </g>
            </svg>
          </div>
          <div class="fls-e_label">att</div>
          <div class="msLabel" data-unit="ms"></div>
        </div>
        <div class="fls-e_control">
          <div class="knob-input fls-e_knob envelope-knob hold">
            <svg class="knob-input_visual" viewBox="0 0 40 40">
              <circle class="focus-indicator" cx="20" cy="20" r="18" fill="#4eccff" filter="url(#glow)"></circle>
              <circle class="indicator-ring-bg" cx="20" cy="20" r="18" fill="#353b3f" stroke="#23292d"></circle>
              <path class="indicator-ring" d="M20,20Z" fill="#4eccff"></path>
              <g class="dial">
                <circle cx="20" cy="20" r="16" fill="url(#grad-dial-soft-shadow)"></circle>
                <ellipse cx="20" cy="22" rx="14" ry="14.5" fill="#242a2e" opacity="0.15"></ellipse>
                <circle cx="20" cy="20" r="14" fill="url(#grad-dial-base)" stroke="#242a2e" stroke-width="1.5"></circle>
                <circle cx="20" cy="20" r="13" fill="transparent" stroke="url(#grad-dial-highlight)" stroke-width="1.5"></circle>
                <circle class="dial-highlight" cx="20" cy="20" r="14" fill="#ffffff"></circle>
                <circle class="indicator-dot" cx="20" cy="30" r="1.5" fill="#4eccff"></circle>
              </g>
            </svg>
          </div>
          <div class="fls-e_label">hold</div>
          <div class="msLabel" data-unit="ms"></div>
        </div>
        <div class="fls-e_control">
          <div class="knob-input fls-e_knob envelope-knob decay">
            <svg class="knob-input_visual" viewBox="0 0 40 40">
              <circle class="focus-indicator" cx="20" cy="20" r="18" fill="#4eccff" filter="url(#glow)"></circle>
              <circle class="indicator-ring-bg" cx="20" cy="20" r="18" fill="#353b3f" stroke="#23292d"></circle>
              <path class="indicator-ring" d="M20,20Z" fill="#4eccff"></path>
              <g class="dial">
                <circle cx="20" cy="20" r="16" fill="url(#grad-dial-soft-shadow)"></circle>
                <ellipse cx="20" cy="22" rx="14" ry="14.5" fill="#242a2e" opacity="0.15"></ellipse>
                <circle cx="20" cy="20" r="14" fill="url(#grad-dial-base)" stroke="#242a2e" stroke-width="1.5"></circle>
                <circle cx="20" cy="20" r="13" fill="transparent" stroke="url(#grad-dial-highlight)" stroke-width="1.5"></circle>
                <circle class="dial-highlight" cx="20" cy="20" r="14" fill="#ffffff"></circle>
                <circle class="indicator-dot" cx="20" cy="30" r="1.5" fill="#4eccff"></circle>
              </g>
            </svg>
          </div>
          <div class="fls-e_label">dec</div>
          <div class="msLabel" data-unit="ms"></div>
        </div>
        <div class="fls-e_control">
          <div class="knob-input fls-e_knob envelope-knob sustain">
            <svg class="knob-input_visual" viewBox="0 0 40 40">
              <circle class="focus-indicator" cx="20" cy="20" r="18" fill="#4eccff" filter="url(#glow)"></circle>
              <circle class="indicator-ring-bg" cx="20" cy="20" r="18" fill="#353b3f" stroke="#23292d"></circle>
              <path class="indicator-ring" d="M20,20Z" fill="#4eccff"></path>
              <g class="dial">
                <circle cx="20" cy="20" r="16" fill="url(#grad-dial-soft-shadow)"></circle>
                <ellipse cx="20" cy="22" rx="14" ry="14.5" fill="#242a2e" opacity="0.15"></ellipse>
                <circle cx="20" cy="20" r="14" fill="url(#grad-dial-base)" stroke="#242a2e" stroke-width="1.5"></circle>
                <circle cx="20" cy="20" r="13" fill="transparent" stroke="url(#grad-dial-highlight)" stroke-width="1.5"></circle>
                <circle class="dial-highlight" cx="20" cy="20" r="14" fill="#ffffff"></circle>
                <circle class="indicator-dot" cx="20" cy="30" r="1.5" fill="#4eccff"></circle>
              </g>
            </svg>
          </div>
          <div class="fls-e_label">sus</div>
          <div class="msLabel" data-unit="%"></div>
        </div>
        <div class="fls-e_control">
          <div class="knob-input fls-e_knob envelope-knob release">
            <svg class="knob-input_visual" viewBox="0 0 40 40">
              <circle class="focus-indicator" cx="20" cy="20" r="18" fill="#4eccff" filter="url(#glow)"></circle>
              <circle class="indicator-ring-bg" cx="20" cy="20" r="18" fill="#353b3f" stroke="#23292d"></circle>
              <path class="indicator-ring" d="M20,20Z" fill="#4eccff"></path>
              <g class="dial">
                <circle cx="20" cy="20" r="16" fill="url(#grad-dial-soft-shadow)"></circle>
                <ellipse cx="20" cy="22" rx="14" ry="14.5" fill="#242a2e" opacity="0.15"></ellipse>
                <circle cx="20" cy="20" r="14" fill="url(#grad-dial-base)" stroke="#242a2e" stroke-width="1.5"></circle>
                <circle cx="20" cy="20" r="13" fill="transparent" stroke="url(#grad-dial-highlight)" stroke-width="1.5"></circle>
                <circle class="dial-highlight" cx="20" cy="20" r="14" fill="#ffffff"></circle>
                <circle class="indicator-dot" cx="20" cy="30" r="1.5" fill="#4eccff"></circle>
              </g>
            </svg>
          </div>
          <div class="fls-e_label">rel</div>
          <div class="msLabel" data-unit="ms"></div>
        </div>
      </div>
    </div>
  </div>
</div>

Create an Instance of KnobInput for Each Knob

Now we need to add the JavaScript for the knobs. Disclaimer: There’s a fair bit of syntax sugar going on here, and as a result, my JavaScript syntax highlighting is not performing well. There aren’t errors, the highlighting is just a bit wonky.

// Adapted from this codepen: https://codepen.io/jhnsnc/pen/mqPGQK
var rgKnobContainer = document.querySelector('.rg-fl-knob');
var rgKnob = new PrecisionInputs.FLStandardKnob(rgKnobContainer);

// Browser specific transform property name
var transformProp = getTransformProperty();

var envelopeKnobStartPositions = [
    0,
    (100*synth.attack)/maxAttack,
    (100*synth.hold)/maxHold,
    (100*synth.decay)/maxDecay,
    50,
    (100*synth.release)/maxRelease
];

// Select all the knobs 
// '...'' is spread operator. This winds up giving us the elements as an Array instead of a NodeList.
var envelopeKnobs = [...document.querySelectorAll('.fls-e_knob.envelope-knob')];

// Create KnobInput instance for each knob. Define, min, max, default and update functions.
// These are in addition to the functions and properties already defined in the KnobInput class definition
var envelopeKnobs = envelopeKnobs.map((el, idx) => new KnobInput(el, {
    visualContext: function() {
        this.indicatorRing = this.element.querySelector('.indicator-ring');
        var ringStyle = getComputedStyle(this.element.querySelector('.indicator-ring-bg'));
        this.r = parseFloat(ringStyle.r) - (parseFloat(ringStyle.strokeWidth) / 2);
        this.indicatorDot = this.element.querySelector('.indicator-dot');
        this.indicatorDot.style[`${transformProp}Origin`] = '20px 20px';
    },
    updateVisuals: function(norm) {
        // also update synth settings
        if (el.classList.contains('attack')) {
            synth.attack = maxAttack * norm;
        } else if (el.classList.contains('hold')) {
            synth.hold = maxHold * norm;
        } else if (el.classList.contains('decay')) {
           synth.decay = maxDecay * norm;
        } else if (el.classList.contains('sustain')) {
           synth.sustain = norm;
        } else if (el.classList.contains('release')) {
           synth.release = maxRelease * norm;
        }

        // update visuals
        var theta = Math.PI * 2 * norm + 0.5 * Math.PI;
        var endX = this.r * Math.cos(theta) + 20;
        var endY = this.r * Math.sin(theta) + 20;
        // using 2 arcs rather than flags since one arc collapses if it gets near 360deg
        this.indicatorRing.setAttribute('d', `M20,20l0,${this.r}${norm> 0.5?`A${this.r},${this.r},0,0,1,20,${20-this.r}`:''}A-${this.r},${this.r},0,0,1,${endX},${endY}Z`);
        this.indicatorDot.style[transformProp] = `rotate(${360*norm}deg)`;
    },
    min: 0,
    max: 100,
    initial: envelopeKnobStartPositions[idx],
}));

// Envelope Visualization. Store the required elements as variables to use in updateVisualization function
var container = document.querySelector('.envelope-visualizer');
var enveloperVisualizer = {
    container: container,
    shape: container.querySelector('.envelope-shape'),
    attack: container.querySelector('.attack'),
    hold: container.querySelector('.hold'),
    decay: container.querySelector('.decay'),
    release: container.querySelector('.release'),
};

// Update the envelope visualizer. Use debounce to avoid overtriggering this function - not more than every 10 ms    
var updateVisualization = debounce(function(evt) {

    var maxPtSeparation = 100; 
    var ptAttack = (maxPtSeparation * envelopeKnobs[0].value / 100);
    var ptHold = (maxPtSeparation * envelopeKnobs[1].value / 100);
    var ptDecay = (maxPtSeparation * envelopeKnobs[2].value / 100);
    var ptSustain = 100 - envelopeKnobs[3].value; // y value
    var ptRelease = (maxPtSeparation * envelopeKnobs[4].value / 100);
    // console.log('ptAttack: ' + ptAttack);
    // console.log('ptHold: ' + ptHold);
    // console.log('ptDecay: ' + ptDecay);
    // console.log('ptSustain: ' + ptSustain);
    // console.log('ptRelease: ' + ptRelease);

    // t for True. True because each has all 
    // the values before it also e.g. release = attack+hold+decay+release
    var tAttack = ptAttack;
    var tHold = ptAttack + ptHold;
    var tDecay = tHold + ptDecay;
    var tSustain = ptSustain;
    var tRelease = tDecay + ptRelease;

    // Set the points on the visulizer graph
    enveloperVisualizer.attack.setAttribute('cx', tAttack);
    enveloperVisualizer.hold.setAttribute('cx', tHold);
    enveloperVisualizer.decay.setAttribute('cx', tDecay);
    enveloperVisualizer.decay.setAttribute('cy', tSustain);
    enveloperVisualizer.release.setAttribute('cx', tRelease);

    // Draw the line between points on the visulaizer graph
    enveloperVisualizer.shape.setAttribute('d', `M${0},100` + `C${0},100,${tAttack},0,${tAttack},0` + `L${tHold},0` + `C${tHold},0,${tDecay},${tSustain},${tDecay},${tSustain}` + `C${tDecay},${tSustain},${tRelease},100,${tRelease},100`);

    // Convert values to correct magnitude and label
    var param = 0;
    if (evt != null) {
        if (evt.target.parentNode.classList.contains('attack')) {
            // param = Math.round(ptAttack / mAttack * 1000);
            param = Math.round(synth.attack*1000);
        } else if (evt.target.parentNode.classList.contains('hold')) {
            param = Math.round(synth.hold*1000);
        } else if (evt.target.parentNode.classList.contains('decay')) {
            param = Math.round(synth.decay*1000);
        } else if (evt.target.parentNode.classList.contains('sustain')) {
            param = Math.round(synth.sustain * mSustain);
        } else if (evt.target.parentNode.classList.contains('release')) {
            param = Math.round(synth.release*1000);
        }
        evt.target.parentNode.parentNode.querySelector('.msLabel').innerHTML = param + evt.target.parentNode.parentNode.querySelector('.msLabel').getAttribute('data-unit');
    }

}, 10);

// When the knob gets turned, call the update functions we just defined above
envelopeKnobs.forEach(knob => {
    knob.addEventListener('change', updateVisualization);
});

// Initialize knob value labels
function initEnvelopeKnobs() {
    $('.envelope-knob.attack').siblings('.msLabel').text(Math.round(synth.attack * 1000) + 'ms');
    $('.envelope-knob.hold').siblings('.msLabel').text(Math.round(synth.hold / mHold * 1000) + 'ms');
    $('.envelope-knob.decay').siblings('.msLabel').text(Math.round(synth.decay / mDecay * 1000) + 'ms');
    $('.envelope-knob.sustain').siblings('.msLabel').text(Math.round(synth.sustain * mSustain) + '%');
    $('.envelope-knob.release').siblings('.msLabel').text(Math.round(synth.release / mRelease * 1000) + 'ms');
}

// Initialize knobs
initEnvelopeKnobs();
updateVisualization();


// Utility functions
function getSupportedPropertyName(properties) {
    for (var i = 0; i < properties.length; i++)
        if (typeof document.body.style[properties[i]] !== 'undefined') return properties[i];
    return null;
}

function getTransformProperty() {
    return getSupportedPropertyName(['transform', 'msTransform', 'webkitTransform', 'mozTransform', 'oTransform']);
}

// Debounce prevents the function from being called to frequently as is likely to happen with a knob
function debounce(func, wait, immediate) {
    var timeout;
    return function() {
        var context = this,
            args = arguments;
        var later = function() {
            timeout = null;
            if (!immediate) func.apply(context, args);
        };
        var callNow = immediate && !timeout;
        clearTimeout(timeout);
        timeout = setTimeout(later, wait);
        if (callNow) func.apply(context, args);
    };
};

First we select the knobs by class with '.fls-e_knob.envelope-knob' into an Array, then override the values in that array with a KnobInput object for each knob. As we assign each KnobInput object, we declare extra update functions on it to update the knob visuals and the synth settings object.

After the knob functionality is done, we declare the updateVisualization() function which updates the visuals of the envelope visualizer. Then we add an event listener to trigger that function when the knobs receive a 'change' event.

Finally, we initialize the knobs and the envelope visualizer. Below that are some helper functions.

Update Resize Functions

Now that the synth envelope is working, we're going to revamp our keyboard resizing code to include the envelope. This will ensure that they bother remain contained within the screen and functional on any device size.

// Scale envelope
var $container = $("#border"); // element we want to fill completely
var $envelope = $('#envelope');
var resizeEnvelope = () => {
    // Scale based on the '#border' element of the synthesizer
    // because this is the element we always want to fill 100%
    // with the keyboard
    var scale = $("#border").width() / $envelope.outerWidth();
    $envelope.css({
        transform: "translate(-50%, -50%) " + "scale(" + scale + ")"
    });
    var height = $envelope.height();
    $('.envelope').css({height: (height + 100*(scale - .5))});
};
window.addEventListener('resize orientationchange', resizeEnvelope);


// Scale Keyboard
var $keys = $("#keys");
var resizeKeyboard = () => {
    // Scale based on the '#border' element of the synthesizer
    // because this is the element we always want to fill 100%
    // with the keyboard
    var scale = $("#border").width() / $keys.outerWidth();
    $keys.css({
        transform: "translate(-50%, 0%) " + "scale(" + scale + ")"
    });
}

// Scale keyboard on screensize change
$(window).on('resize orientationchange', function() {
    resizeKeyboard();
    resizeEnvelope();
});

// Scale keyboard and envelope initially
$(document).ready(function() {
    resizeEnvelope();
    resizeKeyboard(); 
});

That's it! Now you should be able to move the knobs, see a graphic representation of the changes, and hear them as well.


Loading Symbol


Leave a Reply

Your email address will not be published. Required fields are marked *