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 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();
});