tooner/index.js
2025-04-11 11:07:44 -07:00

250 lines
6.4 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.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();
});