330 lines
9.2 KiB
JavaScript
330 lines
9.2 KiB
JavaScript
const kNumKeys = 49;
|
|
const kMaxKeys = 88;
|
|
const kFirstKey = 'C2';
|
|
const kNotes = ['C', 'C#', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'G#', 'A', 'Bb', 'B'];
|
|
const kA0Frequency = 27.5;
|
|
const kSemiToneMultiplier = Math.pow(2, 1/12);
|
|
|
|
const audioCtx = new AudioContext();
|
|
const frequencies = [];
|
|
const holds = [];
|
|
|
|
let globalVolume = new GainNode(audioCtx, {gain: 1.0});
|
|
globalVolume.connect(audioCtx.destination);
|
|
|
|
/*
|
|
* TODO:
|
|
* - smooth frequency adjustments
|
|
* - auto octave update
|
|
* - implement perfect 5/4/3M/3m
|
|
* - keyboard playing (qwerty)
|
|
*/
|
|
|
|
class Envelope {
|
|
constructor() {
|
|
this.envelope = new GainNode(audioCtx);
|
|
}
|
|
|
|
hold(attack=0.040, decay=0.110, sustain=0.8) {
|
|
this.envelope.gain.setValueAtTime(0, audioCtx.currentTime);
|
|
this.envelope.gain.linearRampToValueAtTime(1.0, audioCtx.currentTime + attack);
|
|
this.envelope.gain.linearRampToValueAtTime(sustain, audioCtx.currentTime + decay);
|
|
}
|
|
|
|
release(release=0.200) {
|
|
this.envelope.gain.setTargetAtTime(0.0, audioCtx.currentTime + release, release/3);
|
|
}
|
|
}
|
|
|
|
function bassBoost(f) {
|
|
const t = Math.pow(f / 20000, 1/10);
|
|
const a = 1.0;
|
|
const b = 0.05;
|
|
|
|
return b * t + a * (1 - t);
|
|
}
|
|
|
|
class Voice {
|
|
constructor(baseFrequency) {
|
|
this.oscillators = [];
|
|
this.envelope = new Envelope();
|
|
|
|
let frequency = baseFrequency;
|
|
let gain = 1.0;
|
|
|
|
while (frequency < 20000) {
|
|
const oscillator = new OscillatorNode(audioCtx, {frequency});
|
|
const volume = new GainNode(audioCtx, {gain: bassBoost(frequency) * gain});
|
|
oscillator
|
|
.connect(volume)
|
|
.connect(this.envelope.envelope)
|
|
.connect(globalVolume);
|
|
|
|
this.oscillators.push(oscillator);
|
|
|
|
frequency *= 2;
|
|
gain *= 0.3;
|
|
}
|
|
}
|
|
|
|
play(duration=86400) {
|
|
this.start();
|
|
|
|
setTimeout(() => {
|
|
this.stop();
|
|
}, duration * 1000);
|
|
}
|
|
|
|
start() {
|
|
this.envelope.hold();
|
|
for (let oscillator of this.oscillators) {
|
|
oscillator.start();
|
|
}
|
|
}
|
|
|
|
stop() {
|
|
this.envelope.release();
|
|
for (let oscillator of this.oscillators) {
|
|
//oscillator.stop(0.200);
|
|
}
|
|
}
|
|
|
|
tune(newBaseFrequency) {
|
|
let frequency = newBaseFrequency;
|
|
for (let oscillator of this.oscillators) {
|
|
oscillator.frequency.value = frequency;
|
|
frequency *= 2;
|
|
}
|
|
}
|
|
}
|
|
|
|
function radioValue(name) {
|
|
try {
|
|
return document.getElementsByName(name).values().filter(e => e.checked).next().value.value;
|
|
} catch (e) {
|
|
// TypeError
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function playNote(frequency, duration=86400) {
|
|
if (frequency < kA0Frequency) {
|
|
return undefined;
|
|
}
|
|
const voice = new Voice(frequency);
|
|
voice.play(duration);
|
|
return voice;
|
|
}
|
|
|
|
function keyIndex(key) {
|
|
let [note, octave, ] = key.split(/(\d)/);
|
|
octave = parseInt(octave);
|
|
|
|
return kNotes.indexOf(note) - kNotes.indexOf('A') + octave * 12;
|
|
}
|
|
|
|
function noteFrequency(key) {
|
|
return frequencies[keyIndex(key)];
|
|
}
|
|
|
|
function setFrequency(key, frequency) {
|
|
const index = keyIndex(key);
|
|
for (let f = frequency, i = index; i >= 0; f *= 0.5, i -= 12) {
|
|
frequencies[i] = f;
|
|
}
|
|
for (let f = frequency * 2, i = index + 12; i < 88; f *= 2, i += 12) {
|
|
frequencies[i] = f;
|
|
}
|
|
}
|
|
|
|
function tuneAllEqual() {
|
|
const startFrequency = kA0Frequency;
|
|
let frequency = startFrequency;
|
|
for (let i = 0; i < kMaxKeys; i++) {
|
|
frequencies[i] = frequency;
|
|
frequency *= kSemiToneMultiplier;
|
|
}
|
|
}
|
|
|
|
function updateOptions(key) {
|
|
const noteSpan = document.getElementById("current-note");
|
|
const frequencySpan = document.getElementById("current-frequency");
|
|
noteSpan.innerHTML = key;
|
|
const freq = noteFrequency(key);
|
|
frequencySpan.value = freq.toFixed(2);
|
|
const index = keyIndex(key);
|
|
const hold = document.getElementById("hold");
|
|
if (holds[index] !== undefined) {
|
|
hold.checked = true;
|
|
} else {
|
|
hold.checked = false;
|
|
}
|
|
document.getElementById("adjust").value = 1.0;
|
|
document.getElementById("adjust-value").innerHTML = freq.toFixed(2) + ' Hz';
|
|
}
|
|
|
|
function onKeyClick(event) {
|
|
const keySpan = event.srcElement;
|
|
const key = keySpan.id;
|
|
if (holds[keyIndex(key)] === undefined) {
|
|
playNote(noteFrequency(key), 0.5);
|
|
}
|
|
const currentKey = document.getElementById("current-note").innerHTML;
|
|
if (currentKey !== '-') {
|
|
document.getElementById('tuning-from').value = currentKey;
|
|
}
|
|
updateOptions(key);
|
|
|
|
keySpan.classList.add('playing');
|
|
setTimeout(() => { keySpan.classList.remove('playing'); }, 500)
|
|
}
|
|
|
|
function makeKey(note) {
|
|
const key = document.createElement('span');
|
|
key.id = note;
|
|
key.addEventListener('click', onKeyClick);
|
|
|
|
if (note.includes('#') || note.includes('b')) {
|
|
key.classList.add('blackKey');
|
|
const container = document.createElement('span');
|
|
container.classList.add('blackKeyOuter');
|
|
container.appendChild(key);
|
|
|
|
return container;
|
|
}
|
|
|
|
key.classList.add('whiteKey');
|
|
return key;
|
|
}
|
|
|
|
function setupKeyboard(firstKey=kFirstKey, numKeys=kNumKeys) {
|
|
const keyboard = document.getElementById('keyboard');
|
|
const tuningFrom = document.getElementById('tuning-from');
|
|
keyboard.innerHTML = '';
|
|
|
|
let curIndex = kNotes.indexOf(firstKey.charAt(0));
|
|
let octave = parseInt(firstKey.charAt(1));
|
|
|
|
for (let i = 0; i < numKeys; i++) {
|
|
const note = kNotes[curIndex] + octave;
|
|
const key = makeKey(note)
|
|
keyboard.appendChild(key);
|
|
const opt = document.createElement('option');
|
|
opt.innerHTML = note;
|
|
tuningFrom.appendChild(opt);
|
|
|
|
curIndex += 1;
|
|
if (curIndex >= kNotes.length) {
|
|
octave += 1;
|
|
curIndex = curIndex % kNotes.length;
|
|
}
|
|
}
|
|
}
|
|
|
|
function addHold(key) {
|
|
holds[keyIndex(key)] = playNote(noteFrequency(key));
|
|
document.getElementById(key).classList.add('held');
|
|
}
|
|
|
|
function removeHold(key) {
|
|
const index = keyIndex(key);
|
|
if (holds[index] !== undefined) {
|
|
holds[index].stop();
|
|
}
|
|
delete holds[index];
|
|
document.getElementById(key).classList.remove('held');
|
|
}
|
|
|
|
function refreshHolds() {
|
|
for (let i = 0; i < holds.length; i++) {
|
|
if (holds[i] !== undefined) {
|
|
holds[i].tune(frequencies[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
function tuneFraction(key, fromKey, fraction) {
|
|
const fromIndex = keyIndex(fromKey);
|
|
const freq = frequencies[fromIndex] * fraction;
|
|
setFrequency(key, freq);
|
|
}
|
|
|
|
function tuneEqual(key, fromKey) {
|
|
const index = keyIndex(key);
|
|
const fromIndex = keyIndex(fromKey);
|
|
const diff = index - fromIndex;
|
|
const freq = frequencies[fromIndex] * Math.pow(kSemiToneMultiplier, diff);
|
|
setFrequency(key, freq);
|
|
}
|
|
|
|
function setupListeners() {
|
|
const freq = document.getElementById("current-frequency");
|
|
const adjust = document.getElementById("adjust");
|
|
freq.oninput = e => {
|
|
const key = document.getElementById("current-note").innerHTML;
|
|
const adj = parseFloat(adjust.value);
|
|
const fre = parseFloat(freq.value);
|
|
const newFreq = adj * fre;
|
|
setFrequency(key, newFreq);
|
|
refreshHolds();
|
|
document.getElementById("adjust-value").innerHTML = newFreq.toFixed(2) + ' Hz';
|
|
};
|
|
adjust.oninput = freq.oninput;
|
|
|
|
const hold = document.getElementById("hold");
|
|
hold.onchange = e => {
|
|
const key = document.getElementById("current-note").innerHTML;
|
|
if (hold.checked) {
|
|
addHold(key);
|
|
} else {
|
|
removeHold(key);
|
|
}
|
|
}
|
|
const volume = document.getElementById("volume");
|
|
volume.oninput = e => {
|
|
const val = parseFloat(volume.value);
|
|
globalVolume.gain.value = val;
|
|
refreshHolds();
|
|
}
|
|
document.getElementById("reset").onclick = () => {
|
|
tuneAllEqual();
|
|
refreshHolds();
|
|
const currentKey = document.getElementById("current-note").innerHTML;
|
|
if (currentKey !== '-') {
|
|
updateOptions(currentKey);
|
|
}
|
|
}
|
|
document.getElementById("zero").onclick = () => {
|
|
frequencies.fill(0.0);
|
|
refreshHolds();
|
|
const currentKey = document.getElementById("current-note").innerHTML;
|
|
if (currentKey !== '-') {
|
|
updateOptions(currentKey);
|
|
}
|
|
}
|
|
document.getElementById('tune').onclick = () => {
|
|
const key = document.getElementById("current-note").innerHTML;
|
|
const fromkey = document.getElementById('tuning-from').value;
|
|
const method = radioValue('tuning');
|
|
if (method === 'equal') {
|
|
tuneEqual(key, fromkey);
|
|
} else if (method === '5th') {
|
|
tuneFraction(key, fromkey, 3/2);
|
|
} else if (method === '4th') {
|
|
tuneFraction(key, fromkey, 4/3);
|
|
} else if (method === 'maj3rd') {
|
|
tuneFraction(key, fromkey, 5/4);
|
|
} else if (method === 'min3rd') {
|
|
tuneFraction(key, fromkey, 6/5);
|
|
}
|
|
refreshHolds();
|
|
updateOptions(key);
|
|
}
|
|
}
|
|
|
|
window.addEventListener('load', e => {
|
|
setupKeyboard('C2', 49);
|
|
tuneAllEqual();
|
|
setupListeners();
|
|
});
|