Adjust frequency, hold note, better synth

This commit is contained in:
Paul Mathieu 2025-04-11 09:38:16 -07:00
parent 8fde361246
commit eea0e3f879
3 changed files with 224 additions and 45 deletions

View File

@ -1,17 +1,19 @@
.whiteKey {
color: white;
background-color: white;
border: solid 1px;
border-color: grey;
height: 67pt;
/*
max-height: 60pt;
max-width: 20pt;
*/
position: relative;
left: 7.5pt;
width: 20pt;
display: inline-block;
height: 67pt;
width: 20pt;
}
.blackKeyOuter {
position: relative;
left: -7.5pt;
z-index: 1;
}
.blackKey {
@ -19,12 +21,19 @@
background-color: black;
position: absolute;
z-index: 1;
display: inline-block;
height: 40pt;
width: 15pt;
/*
max-height: 40pt;
max-width: 15pt;
*/
display: inline-block;
}
.playing {
background-color: lime;
}
.held {
background-color: gold;
}
#keyboard {
white-space: nowrap;
}

View File

@ -11,8 +11,65 @@
</head>
<body>
<main class="container">
<div id="keyboard">
</div>
<article>
<div id="keyboard" class="overflow-auto"></div>
<label>
Volume
<input type="range" min="0.0" max="2.0" value="1.0" step="0.01" id="range">
</label>
</article>
<section id="options">
<form>
<article>
<h3 id="current-note">-</h3>
<label>
<input type="checkbox" role="switch" id="hold">
Hold
</label>
</article>
<article>
<label>
<small>Frequency</small>
<input type="number" id="current-frequency" step="0.01">
</label>
</article>
<article>
<fieldset>
<legend><small>Tuning</small></legend>
<label>
<input type="radio" name="tuning">
Equal temperament
</label>
<label>
<input type="radio" name="tuning">
Perfect 5th
</label>
<label>
<input type="radio" name="tuning">
Perfect 4th
</label>
<label>
<input type="radio" name="tuning">
Perfect major 3rd
</label>
<label>
<input type="radio" name="tuning">
Perfect minor 3rd
</label>
<div id="tune-from" style="display: none">
<label>
From:
<select name="tuning-from">
<option selected disabled value></option>
<option>C2</option>
</select>
</label>
</div>
<button>Tune</button>
</fieldset>
</article>
</form>
</section>
</main>
</body>
</html>

173
index.js
View File

@ -1,80 +1,142 @@
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 = [];
function playNote(frequency, duration) {
let currentGain = 1.0;
let globalVolume = 1.0;
/*
* TODO:
* - smooth frequency adjustments
* - auto octave update
* - implement perfect 5/4/3M/3m
* - keyboard playing (qwerty)
*/
function playNote(frequency, duration=86400) {
let currentGain = globalVolume;
const bassBoost = f => {
const t = f / 20000;
const a = 4.0;
const b = 0.2;
const t = Math.pow(f / 20000, 1/10);
const a = 1.0;
const b = 0.05;
return b * t + a * (1 - t);
};
const oscillators = [];
while (frequency < 20000) {
const oscillator = new OscillatorNode(audioCtx);
const oscillator = new OscillatorNode(audioCtx, {frequency});
const gain = new GainNode(audioCtx);
oscillator.type = 'sine';
oscillator.frequency.value = frequency; // value in hertz
oscillator.connect(gain);
const g = bassBoost(frequency) * currentGain;
gain.gain.setValueAtTime(0, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(bassBoost(frequency) * currentGain, audioCtx.currentTime + 0.010);
gain.gain.linearRampToValueAtTime(1e-10, audioCtx.currentTime + 0.500);
gain.connect(audioCtx.destination);
gain.gain.linearRampToValueAtTime(g, audioCtx.currentTime + 0.010);
gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + duration);
oscillator.connect(gain).connect(audioCtx.destination);
oscillator.start();
setTimeout(() => {oscillator.stop();}, duration);
oscillators.push(oscillator);
// break; // only one frequency for now
frequency *= 2;
currentGain *= 0.3;
}
const stop = () => {
for (let oscillator of oscillators) {
oscillator.stop();
}
};
setTimeout(stop, duration*1000);
return stop;
}
function noteFrequency(key) {
function keyIndex(key) {
let [note, octave, ] = key.split(/(\d)/);
octave = parseInt(octave);
const interval = kNotes.indexOf(note) - kNotes.indexOf('A') + octave * 12;
return kNotes.indexOf(note) - kNotes.indexOf('A') + octave * 12;
}
let freq = kA0Frequency * Math.pow(kSemiToneMultiplier, interval);
function noteFrequency(key) {
return frequencies[keyIndex(key)];
}
return freq;
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 note = event.srcElement.id;
const freq = noteFrequency(note);
playNote(freq, 500);
const keySpan = event.srcElement;
const key = keySpan.id;
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');
if (note.includes('#') || note.includes('b')) {
key.classList.add('blackKey');
} else {
key.classList.add('whiteKey');
}
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() {
function setupKeyboard(firstKey=kFirstKey, numKeys=kNumKeys) {
const keyboard = document.getElementById('keyboard');
keyboard.innerHTML = '';
let curIndex = kNotes.indexOf(kFirstKey.charAt(0));
let octave = parseInt(kFirstKey.charAt(1));
let curIndex = kNotes.indexOf(firstKey.charAt(0));
let octave = parseInt(firstKey.charAt(1));
for (let i = 0; i < kNumKeys; i++) {
for (let i = 0; i < numKeys; i++) {
const note = kNotes[curIndex] + octave;
const key = makeKey(note)
keyboard.appendChild(key);
@ -87,6 +149,57 @@ function setupKeyboard() {
}
}
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]();
}
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]();
holds[i] = playNote(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.onchange = e => {
const val = parseFloat(range.value);
globalVolume = val;
refreshHolds();
}
}
window.addEventListener('load', e => {
setupKeyboard();
setupKeyboard('C2', 49);
tuneEqual();
setupListeners();
});