Digital Piano with Web Audio API (7) : Echo and Delay

I added the echo function to the electronic piano on this page.

Moving the Delay and Feedback sliders in the Echo frame changes the echo effect.
Delay specifies the delay between echoes. 0 to 1 second is allowed for Delay. 0.5 seconds Delay causes echoes to be output with a delay of 0.5 seconds.
Feedback specifies the volume of the echoes. If Feedback is set to 50%, the first echo will be 0.5 times louder than the original volume, the next will be 0.25 times louder and the one after that will be 0.125 times louder.

When Delay is close to 0, the Time Scale in the Envelope frame must be shortened or the Echo effect will be difficult to hear.

# 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.

Echo
Delay:
0.0(s) 1.0(s)
Feedback:
0% 100%
Unison
Detune1:
-25 cents 25 cents
Detune2:
-25 cents 25 cents
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:
Current waveform:

1. The Echo effect is realized by providing a DelayNode that causes a time delay and a Feedback GainNode that receives the output from the DelayNode and returns a slightly lower volume sound.
The output of the gain, which changes according to the ADSR (Attack, Decay, Sustain, Release) parameters described on this page, is used as the input and the AudioNodes of the Web Audio API are connected with the configuration in the figure below.

The JavaScript below is the code to create a DelayNode and a Feedback GainNode. It connects the AudioNodes with the configuration in the figure above. As described on this page, the connect() method of the input AudioNode takes the ouput AudioNode as an argument and it connects the input AudioNode and the output AudioNode.

    // DelayNode
    const MAX_DELAY_TIME = 1.0;
    const delayNode = audioContext.createDelay(MAX_DELAY_TIME);
    delayNode.delayTime.value = delay;

    // FeedbackGainNode
    const feedbackGainNode = audioContext.createGain();
    feedbackGainNode.gain.value = feedback;

    // Connection: GainNode(ADSR) -> Audio Output
    gainNode.connect(audioContext.destination);
    // Connection: GainNode(ADSR) -> FeedbackGainNode -> DelayNode -> Audio Output
    gainNode.connect(feedbackGainNode).connect(delayNode).connect(audioContext.destination);
    // Connection: DelayNode -> FeedbackGainNode
    delayNode.connect(feedbackGainNode);

2. Below is a network of the AudioNode connections that are configured when each key is pressed. The first GainNode receives inputs from the OscillatorNode corresponding to the frequency of the pressed key and the OscillatorNodes whose frequencies are off by Detune1 and Detune2. The GainNode varies its gain according to the parameters specified by ADSR and outputs the audio data to the AudioDestinationNode (speaker) and a second GainNode (Feedback GainNode).

First, the output from the first GainNode is output from the speaker. Next, the output from the DelayNode is output from the speaker after the time specified by the Delay slider. The second output volume will be lower than the first output by the percentage of the Feedback slider.

The output of the DelayNode is also output to the Feedback GainNode, converted to a smaller sound and input again to the DelayNode. The even quieter sound will be output from the DelayNode after a delay specified by the Delay slider. This process will be repeated by the DelayNode and the Feedback GainNode. The output volume will be reduced repeatedly.

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;
}

.synthesizer-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;
    vertical-align: middle;
    white-space: nowrap;
    border: none;
}
</style>

3.2. HTML

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

<fieldset class="synthesizer-fieldset">
    <legend>Echo</legend>
    <table class="table-border-none">
        <tr>
            <td>Delay:<br/>0.0(s) <input type="range" min="0.0" max="1.0" step="0.01" value="0.5" name="delay"> 1.0(s)</td>
        </tr>
        <tr>
            <td>Feedback:<br/>0% <input type="range" min="0.0" max="1.0" step="0.01" value="0.5" name="feedback"> 100%</td>
        </tr>
    </table>
</fieldset>

<fieldset class="synthesizer-fieldset">
    <legend>Unison</legend>
    <table class="table-border-none">
        <tr>
            <td>Detune1:<br/>-25 cents <input type="range" min="-25" max="25" step="0.1" value="0" name="detune1"> 25 cents</td>
        </tr>
        <tr>
            <td>Detune2:<br/>-25 cents <input type="range" min="-25" max="25" step="0.1" value="0" name="detune2"> 25 cents</td>
        </tr>
    </table>
</fieldset>

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

    <table class="table-border-none">
        <tr>
            <td>Attack (Time):<br/>0% <input type="range" min="0.0" max="1.0" step="0.01" value="0.1" name="attack"> 100%</td>
        </tr>
        <tr>
            <td>Decay (Time):<br/>0% <input type="range" min="0.0" max="1.0" step="0.01" value="0.2" name="decay"> 100%</td>
        </tr>
        <tr>
            <td>Sustain (Volume):<br/>0% <input type="range" min="0.0" max="1.0" step="0.01" value="0.5" name="sustain"> 100%</td>
        </tr>
        <tr>
            <td>Release (Time):<br/>0% <input type="range" min="0.0" max="1.0" step="0.01" value="0.5" name="release"> 100%</td>
        </tr>
        <tr>
            <td>Time Scale:<br/>0.0(s) <input type="range" min="0.0" max="2.0" step="0.01" value="1.0" name="time-scale"> 2.0(s)</td>
        </tr>
    </table>
</fieldset>

<div class="settingsBar">
    <div class="left">
        <span>Volume: </span>
        <input type="range" min="0.0" max="1.0" step="0.01" value="0.5" list="volumes" name="volume">
        <datalist id="volumes">
            <option value="0.0" label="Mute">
            <option value="1.0" label="100%">
        </datalist>
    </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">Triangle</option>
            <option value="custom" selected>Custom</option>
        </select>
    </div>
</div>

3.3. JavaScript

<script>
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const oscillatorMap = 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']");
const detune1Control = document.querySelector("input[name='detune1']");
const detune2Control = document.querySelector("input[name='detune2']");
const delayControl = document.querySelector("input[name='delay']");
const feedbackControl = document.querySelector("input[name='feedback']");

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;
}

class Oscillator {

    constructor(type, frequency, detune, gainNode) {
        this.oscillator = audioContext.createOscillator();
        this.oscillator.connect(gainNode);

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

        this.oscillator.frequency.value = frequency;
        this.oscillator.detune.value = detune;
    }

    start() {
        this.oscillator.start();
    }

    stop(t) {
        this.oscillator.stop(t);
    }
}

class Oscillators {

    constructor(type, frequency, detune1, detune2, gainNode) {

        this.oscillator = new Oscillator(type, frequency, 0, gainNode);
        this.detune1 = detune1;
        this.detune2 = detune2;
        this.gainNode = gainNode;

        if (detune1 != 0) {
            this.oscillator1 = new Oscillator(type, frequency, detune1, gainNode);
        }

        if (detune2 != 0) {
            this.oscillator2 = new Oscillator(type, frequency, detune2, gainNode);
        }
    }

    start() {
        this.oscillator.start();

        if (this.detune1 != 0) {
            this.oscillator1.start();
        }

        if (this.detune2 != 0) {
            this.oscillator2.start();
        }
    }

    stop(t) {
        this.oscillator.stop(t);

        if (this.detune1 != 0) {
            this.oscillator1.stop(t);
        }

        if (this.detune2 != 0) {
            this.oscillator2.stop(t);
        }
    }

    getGainNode() {
        return this.gainNode;
    }
}

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 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);
    const detune1 = parseFloat(detune1Control.value);
    const detune2 = parseFloat(detune2Control.value);
    const delay = parseFloat(delayControl.value);
    const feedback = parseFloat(feedbackControl.value);

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

    // DelayNode
    const MAX_DELAY_TIME = 1.0;
    const delayNode = audioContext.createDelay(MAX_DELAY_TIME);
    delayNode.delayTime.value = delay;

    // FeedbackGainNode
    const feedbackGainNode = audioContext.createGain();
    feedbackGainNode.gain.value = feedback;

    // Connection: GainNode(ADSR) -> Audio Output
    gainNode.connect(audioContext.destination);
    // Connection: GainNode(ADSR) -> FeedbackGainNode -> DelayNode -> Audio Output
    gainNode.connect(feedbackGainNode).connect(delayNode).connect(audioContext.destination);
    // Connection: DelayNode -> FeedbackGainNode
    delayNode.connect(feedbackGainNode);

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

    const oscillators = new Oscillators(type, frequency, detune1, detune2, gainNode);
    oscillators.start();

    const keyID = note + octave;
    oscillatorMap.set(keyID, oscillators);
}

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 oscillators = oscillatorMap.get(keyID);
    const gainNode = oscillators.getGainNode();

    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);

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

Leave a Reply

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

CAPTCHA