Web Audio APIで電子ピアノ(7) : EchoとDelay

こちらのページの電子ピアノにエコーの機能を追加しました。

Echoの枠の中のDelayとFeedbackのスライダーを動かすとエコーの効果が変化します。Delayでエコーの時間遅れの秒数を指定します。Delayで指定できる秒数は0秒から1秒の範囲にしています。Delayを0.5秒にすると0.5秒だけ遅れてエコーが出力されます。Feedbackではエコーの音量を指定します。Feedbackの大きさを50%にすると、最初のエコーの大きさは元の音量の0.5倍、次のエコーの大きさは0.25倍、その次のエコーの大きさは0.125倍になります。

Delayの大きさが0に近いときはEnvelopeの枠の中のTime Scale等の時間を短くしないとEchoの効果がわかり辛くなります。

# Windows 11のPCのChromeとEdgeではほぼ安定して動作しますが、スマートフォンでボタンを二回連続で押したとき等の動作が不安定なことを確認しています。

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. Echoの効果は時間遅れを生じさせるDelayNodeと、DelayNodeの出力を受け取って少し小さな音量の音を返すGainNode(Feedback)を用意して実現しています。こちらのページで用意したADSR(Attack, Decay, Sustain, Release)のパラメータにしたがって変化するゲインの出力を入力とし、Web Audio APIのAudioNodeを下の図のような構成で接続しました。

下記のJavaScriptは、DelayNodeとフィードバック用のGainNodeを用意して上の図のような構成でAudioNodeを接続するコードになります。こちらのページに記載されているように入力側のAudioNodeのconnect()メソッドの引数として出力先のAudioNodeを指定し、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. 下の図はこのページのJavaScriptで各鍵盤を押したときに構成されるAudioNodeの接続図です。各鍵盤の音の周波数に対応するOscillatorNodeと周波数がDetune1とDetune2だけずれたOscillatorNodeからの入力を一つ目のGainNodeが受け取ります。一つ目のGainNodeはADSRで指定したパラメータにしたがってゲインを変化させ、音データをAudioDestinationNode(スピーカー)とフィードバック用の二つ目のGainNodeに出力します。

まず、一つ目のGainNodeからの出力がスピーカーから出力されます。次にDelayスライダーで指定した秒数だけ遅れてDelayNodeの出力がスピーカーから出力されます。このとき出力される音の大きさはFeedbackスライダーで指定した割合を掛けた大きさになっていて、一度目の出力よりも小さな音になっています。

DelayNodeの出力はフィードバック用のGainNodeにも出力され、さらに小さな音に変換されてDelayNodeにもう一度入力されます。さらに小さくなった音はDelayスライダーで指定した秒数だけ遅れてDelayNodeから出力されます。フィードバック用のGainNodeで音を小さくさせながらこれを繰り返します。

3. 修正後のコード全体

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_japanese = note_names[n % 12][0];
        noteFreq[octave][note_name_sharp_japanese] = 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 === 'ド' || whiteKeyName === 'レ' || whiteKeyName === 'ファ' ||
                 whiteKeyName === 'ソ' || whiteKeyName === 'ラ') {

                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='ファ'][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