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 tuneFactor(key, fromKey, factor) { const index = keyIndex(key); const fromIndex = keyIndex(fromKey); if (index > fromIndex) { setFrequency(key, frequencies[fromIndex] * factor); } else { setFrequency(key, frequencies[fromIndex] / factor); } } 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 = document.getElementById('tuning').value; if (method === 'equal') { tuneEqual(key, fromkey); } else if (method === 'perfect-5th') { tuneFactor(key, fromkey, 3/2); } else if (method === 'lower-5th') { tuneFactor(key, fromkey, 2/3); } else if (method === 'quartmean-5th') { tuneFactor(key, fromkey, Math.pow(5, 1/4)); } else if (method === 'quartmean-lower-5th') { tuneFactor(key, fromkey, Math.pow(5, 1/4)); } else if (method === 'perfect-3rd') { tuneFactor(key, fromkey, 5/4); } refreshHolds(); updateOptions(key); } } window.addEventListener('load', e => { setupKeyboard('C2', 49); tuneAllEqual(); setupListeners(); });