Digital Piano with Web Audio API (5) : ADSR(Attack, Decay, Sustain, Release)

I updated the digital piano on this page so that the gain changes according to the ADSR (Attack, Decay, Sustain, Release) parameters when the keyboard keys are pressed and released.

# It works mostly stable with Chrome and Edge on Windows 11 PCs, but it is sometimes unstable on smartphones when the same keys are pressed repeatedly with short intervals.

Envelope
Attack:
(Time)
0% 100% Release:
(Time)
0% 100%
Decay:
(Time)
0% 100% Time
Scale:
0.0(s) 2.0(s)
Sustain:
(Volume)
0% 100%
Envelope
Attack:
(Time)
0%100%
Decay:
(Time)
0%100%
Sustain:
(Volume)
0%100%
Release:
(Time)
0%100%
Time
Scale:
0.0(s)2.0(s)
Volume: Mute100%
Current waveform:
Volume:
Current waveform:

The ADSR (Attack, Decay, Sustain, Release) parameters are changed by using the sliders in the Envelope frame.

  • Attack Attack specifies the length ot the time between the keyboard pressing and the time when the gain reaches maximum. The Attack length is the Attack slider ratio multiplied by the the Time Scale slider value.
  • Decay The time constant for the gain to decrease from the maximum to the gain specified by Sustain. The Decay time constant is the Decay slider ratio multiplied by the Time Scale slider value.
  • Sustain The Sustain level is the gain level after Attack and Decay. The level is the Sustain slider ratio multiplied by the Volume slider value. While Attack, Decay and Release specify the length of time, Sustain specifies the gain level.
  • Release Release specifies the length of the time between the release of the key and the disappearance of the sound. The Release length is Release slider ratio multiplied by the Time Scale slider value.

1. Code specifying the time variation of the gain when a key is pressed

    gainNode.gain.setValueAtTime(0, t_pressed);
    gainNode.gain.linearRampToValueAtTime(volume, t_pressed + attackDuration);
    gainNode.gain.setTargetAtTime(sustainLevel * volume, t_pressed + attackDuration, decayDuration);

The above code specifies the time variation of the gain after pressing the key. If you press and hold the key, the gain will be set to the level specified by Sustain after Decay.

  • gainNode.gain.setValueAtTime(0, t_pressed) sets the initial gain value 0 when the key is pressed.
  • gainNode.gain.linearRampToValueAtTime(volume, t_pressed + attackDuration) sets the gain value after Attack duration. The gain varies in proportion to time from 0 to “volume”.
  • gainNode.gain.setTargetAtTime(sustainLevel * volume, t_pressed + attackDuration, decayDuration) sets the gain value after Decay. The gain starts changing exponentially from the time specified by the second argument, “t_pressed + attackDuration”. It approaches the value specified by the third argument, “sustainLevel * volume”.
Note: Gain schedule functions

  • linearRampToValueAtTime(value, endTime)
    The gain varies with time according to the following equation as described on this page.

    \[v(t) = V_0 + (V_1 – V_0) \frac{t – T_0}{T_1 – T_0}\]

    In the above equation, $T_0$ is the time of the previous event, $V_0$ is the gain at time $T_0,$ $T_1$ is the function argument “endTime” and $V_1$ is the function argument “value”. $v(t)$ specifies the gain at time $t$ that satisfies $T_0 \leq t < T_1$. The $v(t)$ varies linearly with time.

  • setTargetAtTime(target, startTime, timeConstant)
    The gain varies with time according to the following equation as described on this page.

    \[v(t) = V_1 + (V_0 – V_1) e^{-\frac{t – T_0}{\tau}}\]

    In the above equation, $T_0$ is the function argument “startTime”, $V_0$ is the gain at time $T_0,$ $V_1$ is the function argument “target” and $\tau$ is the function argument “timeConstant”. $v(t)$ specifies the gain at time $t$ that satisfies $T_0 \leq t$. $v(t)$ approaches $V_1$ according to the exponential function.

2. Code specifying the time variation of the gain when a key is released

    gainNode.gain.cancelScheduledValues(t_released);
    gainNode.gain.setValueAtTime(gainNode.gain.value, t_released);
    gainNode.gain.linearRampToValueAtTime(0, t_released + releaseDuration);
  • gainNode.gain.cancelScheduledValues(t_released) cancels the gain schedule after the key is released. For example, if the key is released during the Attack period, the processes after the middle of Attack, Decay and Sustain will be canceled. In another example, if the key is held down for a long time and the sound continues to be output with the gain specified with Sustain, releasing the key will cancel the continued output of the sound.
  • gainNode.gain.setValueAtTime(gainNode.gain.value, t_released) sets the gain at the time the key is released.
  • gainNode.gain.linearRampToValueAtTime(0, t_released + releaseDuration) turns off the sound after the Release period. The gain decreases in proportion to time.

3. Entire code after modification

3.1. css

<style type="text/css">
.container {
    overflow-x: scroll;
    overflow-y: hidden;
    width: 100%;
    height: 180px;
    white-space: nowrap;
    margin: 10px;
}

.keyboard {
    width: auto;
    padding: 0;
    margin: 0;
}

.key-set-parent {
    position: relative;
    display: inline-block;
    width: 40px;
    height: 120px;
}

.key {
    position: relative;
    cursor: pointer;
    font: 10px "Open Sans", "Lucida Grande", "Arial", sans-serif;
    border: 1px solid black;
    border-radius: 5px;
    width: 40px;
    height: 120px;
    text-align: center;
    box-shadow: 2px 2px darkgray;
    display: inline-block;
    margin-right: 3px;
    user-select: none;
    -moz-user-select: none;
    -webkit-user-select: none;
    -ms-user-select: none;
}

.key.black-key {
    position: absolute;
    background-color: #000;
    color: #fff;
    width: 36px;
    height: 80px;
    top: 0px;
    left: 22px;
    z-index: 1;
    pointer-events: auto;
    vertical-align: top;
}

.key div {
    position: absolute;
    bottom: 0;
    text-align: center;
    width: 100%;
    pointer-events: none;
}

.key div sub {
    font-size: 8px;
    pointer-events: none;
}

.key:hover {
    background-color: #eef;
}

.key.black-key:hover {
    background-color: #778;
}

.key:active {
    background-color: #000;
    color: #fff;
}

.key.black-key:active {
    background-color: #fff;
    color: #000;
}

.octave {
    display: inline-block;
    padding: 0 6px 0 0;
}

.settingsBar {
    padding-top: 8px;
    font: 14px "Open Sans", "Lucida Grande", "Arial", sans-serif;
    position: relative;
    vertical-align: middle;
    width: 100%;
    height: 60px;
}

.left {
    width: 50%;
    position: absolute;
    left: 0;
    display: table-cell;
    vertical-align: middle;
}

.left span, .left input {
    vertical-align: middle;
}

.right {
    width: 50%;
    position: absolute;
    right: 0;
    display: table-cell;
    vertical-align: middle;
}

.right span {
    vertical-align: middle;
}

.right input {
    vertical-align: baseline;
}

.envelope-fieldset {
    padding-top: 8px;
    position: relative;
    font: 18px "Open Sans", "Lucida Grande", "Arial", sans-serif;
    left: 0;
    display: table-cell;
    border-style: solid;
    vertical-align: middle;
    border-color: #000;
    background-color: #eee;
}

.table-border-none, .table-border-none td {
    font: 14px "Open Sans", "Lucida Grande", "Arial", sans-serif;
    left: 0;
    width: 45%;
    vertical-align: middle;
    white-space: nowrap;
    border: none;
}
</style>

3.2. HTML

<div class="container">
    <div class="keyboard"></div>
</div>

<fieldset class="envelope-fieldset">
    <legend>Envelope</legend>

    <table class="table-border-none">
        <tr>
            <td>Attack:<br/>(Time)</td>
            <td>0%</td>
            <td><input type="range" min="0.0" max="1.0" step="0.01" value="0.1" name="attack"></td>
            <td>100%</td>

            <td>Release:<br/>(Time)</td>
            <td>0%</td>
            <td><input type="range" min="0.0" max="1.0" step="0.01" value="0.5" name="release"></td>
            <td>100%</td>
        </tr>

        <tr>
            <td>Decay:<br/>(Time)</td>
            <td>0%</td>
            <td><input type="range" min="0.0" max="1.0" step="0.01" value="0.5" name="decay"></td>
            <td>100%</td>

            <td>Time<br/>Scale:</td>
            <td>0.0(s)</td>
            <td><input type="range" min="0.0" max="2.0" step="0.01" value="1.0" name="time-scale"></td>
            <td>2.0(s)</td>
        </tr>

        <tr>
            <td>Sustain:<br/>(Volume)</td>
            <td>0%</td>
            <td><input type="range" min="0.0" max="1.0" step="0.01" value="0.5" name="sustain"></td>
            <td>100%</td>

            <td></td>
            <td></td>
            <td></td>
            <td></td>
        </tr>
    </table>
</fieldset>

<div class="settingsBar">
    <div class="left">
        <table class="table-border-none">
            <tr>
                <td>Volume: </td>
                <td>Mute<input type="range" min="0.0" max="1.0" step="0.01" value="0.5" name="volume">100%</td>
            </tr>
        </table>
    </div>

    <div class="right">
        <span>Current waveform: </span>
        <select name="waveform">
            <option value="sine">Sine</option>
            <option value="square">Square</option>
            <option value="sawtooth">Sawtooth</option>
            <option value="triangle" selected>Triangle</option>
            <option value="custom">Custom</option>
        </select>
    </div>
</div>

3.3. JavaScript

<script>
const audioContext = new (window.AudioContext || window.webkitAudioContext)();

const oscillatorMap = new Map();
const gainNodeMap = new Map();

const keyboard = document.querySelector(".keyboard");
const wavePicker = document.querySelector("select[name='waveform']");
const volumeControl = document.querySelector("input[name='volume']");
const attackControl = document.querySelector("input[name='attack']");
const decayControl = document.querySelector("input[name='decay']");
const sustainControl = document.querySelector("input[name='sustain']");
const releaseControl = document.querySelector("input[name='release']");
const timeScaleControl = document.querySelector("input[name='time-scale']");

let noteFreq = null;
let customWaveform = null;
let sineTerms = null;
let cosineTerms = null;

const note_names =
[
    ["ラ", "", "A", ""],
    ["ラ#", "シ$\\flat$", "A#", "B$\\flat$"],
    ["シ", "","B", ""],
    ["ド", "","C", ""],
    ["ド#", "レ$\\flat$","C#", "D$\\flat$"],
    ["レ", "","D", ""],
    ["レ#", "ミ$\\flat$","D#", "E$\\flat$"],
    ["ミ", "","E", ""],
    ["ファ", "","F", ""],
    ["ファ#", "ソ$\\flat$","F#", "G$\\flat$"],
    ["ソ", "", "G", ""],
    ["ソ#", "ラ$\\flat$", "G#", "A$\\flat$"]
];

setup();

// -------------------------------------------------------
// functions
// -------------------------------------------------------

function createNoteTable() {

    let noteFreq = [];
    for (let octave = 0; octave < 9; octave++) {
        noteFreq[octave] = [];
    }

    for (let n = 0; n < 88; n++) {

        const frequency = getAudioFrequency(n);

        let octave = parseInt(n/12);
        if (n % 12 >= 3) {
            octave++;
        }

        const note_name_sharp_english = note_names[n % 12][2];
        noteFreq[octave][note_name_sharp_english] = frequency;
    }

    return noteFreq;
}

function getAudioFrequency(n) {
    return 27.5 * ( Math.pow( Math.pow(2, 1/12), n) );
}

function setup() {
    noteFreq = createNoteTable();

    noteFreq.forEach(function(keys, idx) {

        const keyList = Object.entries(keys);
        const octaveElem = document.createElement("div");
        octaveElem.className = "octave";

        for (let i = 0; i < keyList.length; i++) {

            const keySetElem = document.createElement("div");
            keySetElem.className = "key-set-parent";

            const whiteKey = keyList[i];
            const whiteKeyName = whiteKey[0];

            const whiteKeyElem = createKey(whiteKeyName, idx, whiteKey[1], 'white-key');
            keySetElem.appendChild(whiteKeyElem);

            if (whiteKeyName === 'A' || whiteKeyName === 'C' || whiteKeyName === 'D' ||
                whiteKeyName === 'F' || whiteKeyName === 'G') {

                const blackKey = keyList[++i];

                if (blackKey != undefined) {
                    const blackKeyName = blackKey[0];
                    const blackKeyElem = createKey(blackKeyName, idx, blackKey[1], 'black-key');
                    keySetElem.appendChild(blackKeyElem);
                }
            }

            octaveElem.appendChild(keySetElem);
        }

        keyboard.appendChild(octaveElem);
    });

    document.querySelector("div[data-note='F'][data-octave='5']").scrollIntoView(false);

    sineTerms = new Float32Array([0, 0, 1, 0, 1]);
    cosineTerms = new Float32Array(sineTerms.length);
    customWaveform = audioContext.createPeriodicWave(cosineTerms, sineTerms);
}

function createKey(note, octave, freq, keyColor) {
    const keyElement = document.createElement("div");
    const labelElement = document.createElement("div");

    if (keyColor === 'black-key') {
        keyElement.className = "key black-key";
    } else {
        keyElement.className = "key";
    }

    keyElement.dataset["octave"] = octave;
    keyElement.dataset["note"] = note;
    keyElement.dataset["frequency"] = freq;

    labelElement.innerHTML = note + "<sub>" + octave + "</sub>";
    keyElement.appendChild(labelElement);

    keyElement.addEventListener("mousedown", notePressed, false);
    keyElement.addEventListener("mouseup", noteReleased, false);
    keyElement.addEventListener("mouseleave", noteReleased, false);

    keyElement.addEventListener("touchstart", notePressed, false);
    keyElement.addEventListener("touchend", noteReleased, false);
    keyElement.addEventListener("touchmove", noteReleased, false);
    keyElement.addEventListener("touchcancel", noteReleased, false);

    return keyElement;
}

function notePressed(event) {

    event.preventDefault();

    const dataset = event.target.dataset;

    if (dataset["pressed"]) {
        return;
    }

    dataset["pressed"] = "yes";

    const octave = dataset["octave"];
    const note = dataset["note"];
    const frequency = dataset["frequency"];

    const oscillator = audioContext.createOscillator();

    const t_pressed = audioContext.currentTime;
    const volume = parseFloat(volumeControl.value);
    const timeScale = parseFloat(timeScaleControl.value);
    const attackDuration = parseFloat(attackControl.value) * timeScale;
    const decayDuration = parseFloat(decayControl.value) * timeScale;
    const sustainLevel = parseFloat(sustainControl.value);

    // Attack -> Decay -> Sustain
    const gainNode = audioContext.createGain();
    gainNode.connect(audioContext.destination);
    gainNode.gain.setValueAtTime(0, t_pressed);
    gainNode.gain.linearRampToValueAtTime(volume, t_pressed + attackDuration);
    gainNode.gain.setTargetAtTime(sustainLevel * volume, t_pressed + attackDuration, decayDuration);

    oscillator.connect(gainNode);

    const type = wavePicker.options[wavePicker.selectedIndex].value;

    if (type == "custom") {
        oscillator.setPeriodicWave(customWaveform);
    } else {
        oscillator.type = type;
    }

    oscillator.frequency.value = frequency;
    oscillator.start();

    const keyID = note + octave;
    oscillatorMap.set(keyID, oscillator);
    gainNodeMap.set(keyID, gainNode);
}

function noteReleased(event) {

    event.preventDefault();

    const dataset = event.target.dataset;

    if (!dataset["pressed"]) {
        return;
    }

    delete dataset["pressed"];

    const octave = dataset["octave"];
    const note = dataset["note"];
    const frequency = dataset["frequency"];
    const keyID = note + octave;

    const oscillator = oscillatorMap.get(keyID);
    const gainNode = gainNodeMap.get(keyID);

    const t_released = audioContext.currentTime;
    const timeScale = parseFloat(timeScaleControl.value);
    const releaseDuration = parseFloat(releaseControl.value) * timeScale;

    gainNode.gain.cancelScheduledValues(t_released);
    gainNode.gain.setValueAtTime(gainNode.gain.value, t_released);
    gainNode.gain.linearRampToValueAtTime(0, t_released + releaseDuration);

    oscillator.stop(t_released + releaseDuration);
}
</script>

Leave a Reply

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

CAPTCHA