I added a synthesizer’s unison function to the electronic piano on this page.
By moving the Detune1 and Detune2 sliders in the Unison frame, you can superimpose oscillator sounds with slightly different frequencies. The oscillator with the frequency of the keyboard note is superimposed by the oscillators with frequencies differing only by Detune1 and Detune2.
The values of Detune1 and Detune2 are 0 before moving the sliders. You will hear only one oscillator’s sound with the same pitch as the note on the keyboard.
By moving the Detune1 and Detune2 sliders, oscillators with slightly different frequencies will be superimposed. It causes the volume of the sound to oscillate.
# 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.
1. The unit of the frequency shifted by Detune1 and Detune2 is cent. The frequency $f(n)$ which is higher than $f_0$ by $n$ cents is described by the following equation
\[f(n) = f_0 \cdot 2^ \frac{n}{1200}\]
The frequency that is 100 cents higher than $f_0$ is $f_0 \cdot 2^\frac{1}{12}$, which is 1 semitone higher than $f_0$ in 12-tone equal temperament. The frequency that is 1200 cents higher than $f_0$ is twice the frequency of $f_0$, which is 1 octave higher than $f_0$.
If the frequency deviation exceeds 25 cents, it will not sound very good. The detune specified by the slider is within 25 cents.
2. As shown in the JavaScript below, I prepared a set of three oscillators and superimposed the oscillator sounds with different frequencies.
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; } }
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>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" selected>Sawtooth</option> <option value="triangle">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 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']"); 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); // 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); 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>