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.baseFrequency = 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) { const adjust = newBaseFrequency / this.baseFrequency; this.baseFrequency = newBaseFrequency; for (let oscillator of this.oscillators) { oscillator.frequency.value *= adjust; } } } function playNote(frequency, duration=86400) { 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) { frequencies[keyIndex(key)] = frequency; } function tuneEqual() { 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; frequencySpan.value = noteFrequency(key).toFixed(2); const index = keyIndex(key); const hold = document.getElementById("hold"); if (holds[index] !== undefined) { hold.checked = true; } else { hold.checked = false; } } function onKeyClick(event) { const keySpan = event.srcElement; const key = keySpan.id; if (holds[keyIndex(key)] === undefined) { playNote(noteFrequency(key), 0.5); } 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'); 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); 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 setupListeners() { const freq = document.getElementById("current-frequency"); freq.onchange = e => { const key = document.getElementById("current-note").innerHTML; setFrequency(key, parseFloat(freq.value)); refreshHolds(); }; freq.oninput = freq.onchange; const hold = document.getElementById("hold"); hold.onchange = e => { const key = document.getElementById("current-note").innerHTML; if (hold.checked) { addHold(key); } else { removeHold(key); } } const range = document.getElementById("range"); range.oninput = e => { const val = parseFloat(range.value); globalVolume.gain.value = val; refreshHolds(); } } window.addEventListener('load', e => { setupKeyboard('C2', 49); tuneEqual(); setupListeners(); });