こちらのページの電子ピアノにシンセサイザーのユニゾン機能を追加しました。
Unisonの枠の中のDetune1とDetune2のスライダーを動かすと周波数がわずかに異なるオシレーターの音を重ねることができます。鍵盤の音の高さのオシレーターに、周波数がDetune1だけ異なるオシレーターとDetune2だけ異なるオシレーターの音を重ねています。
スライダーを動かす前のDetune1とDetune2の値は0で、鍵盤の音の高さに対応した一つのオシレーターの音だけが鳴るようにしています。
Detune1とDetune2のスライダーを動かして、各鍵盤の音の周波数と少しだけ異なる周波数のオシレーターを重ねることで音のうなりが発生し、音の大きさが振動します。
# Windows 11のPCのChromeとEdgeではほぼ安定して動作しますが、スマートフォンでボタンを二回連続で押したとき等の動作が不安定なことを確認しています。
1. Detune1とDetune2でずらす周波数の大きさの単位はセントです。基本となる周波数 $f_0$ に対し、$n$セント高い周波数の音の高さは次の式で表されます。
\[f(n) = f_0 \cdot 2^ \frac{n}{1200}\]
周波数$f_0$の音よりも周波数が100セント高い音の周波数は$f_0 \cdot 2^\frac{1}{12}$となり、12平均律で$f_0$よりも半音だけ高い音の周波数になります。周波数$f_0$の音よりも1200セント高い音の周波数は$f_0$の2倍の周波数の音になり、$f_0$よりも1オクターブだけ高い周波数の音になります。
周波数のずれが25セントを超えるとあまり心地良く聞こえないため、スライダーで指定するDetuneの大きさは25セント以内にしています。
2. 下記のJavaScriptのように3つのオシレーターをセットにしたコードを用意して、周波数の異なるオシレーターの音を重ねるようにしました。
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. 修正後のコード全体
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_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);
// 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>