Virtual Piano - Day 19
Advanced LevelIn Western music, the A4 note is standardized at 440Hz. Every octave doubles the frequency. Each semitone is the 12th root of 2 apart.
// Standard note frequencies for octave 4 (A4 = 440Hz)
const noteFrequencies = {
'C': 261.63, // Middle C
'C#': 277.18,
'D': 293.66,
'D#': 311.13,
'E': 329.63,
'F': 349.23,
'F#': 369.99,
'G': 392.00,
'G#': 415.30,
'A': 440.00, // Concert pitch standard
'A#': 466.16,
'B': 493.88
};
// Calculate frequency for any octave
function getFrequency(note, octave) {
const baseFreq = noteFrequencies[note];
// Each octave doubles the frequency
// octave 4 is our reference, so: freq * 2^(octave - 4)
return baseFreq * Math.pow(2, octave - 4);
}
// Examples:
getFrequency('A', 4); // 440 Hz
getFrequency('A', 5); // 880 Hz (one octave up)
getFrequency('A', 3); // 220 Hz (one octave down)
Modern pianos use equal temperament tuning where each semitone ratio is exactly 2^(1/12) = 1.0595. This allows playing in any key.
Real piano sound is complex - we simulate it by layering oscillators with harmonics and applying a low-pass filter for warmth.
function createPianoSound(frequency, velocity = 0.8) {
// Create two oscillators for richer sound
const osc1 = audioCtx.createOscillator();
const osc2 = audioCtx.createOscillator();
const gainNode = audioCtx.createGain();
const filterNode = audioCtx.createBiquadFilter();
// Triangle wave for body, sine for brightness
osc1.type = 'triangle';
osc2.type = 'sine';
// Fundamental frequency + first harmonic (octave)
osc1.frequency.setValueAtTime(frequency, audioCtx.currentTime);
osc2.frequency.setValueAtTime(frequency * 2, audioCtx.currentTime);
// Low-pass filter for warmth (cutoff at 6x fundamental)
filterNode.type = 'lowpass';
filterNode.frequency.setValueAtTime(frequency * 6, audioCtx.currentTime);
filterNode.Q.value = 1;
// Audio routing: oscillators -> filter -> gain -> output
osc1.connect(filterNode);
osc2.connect(filterNode);
filterNode.connect(gainNode);
gainNode.connect(masterGain);
return { osc1, osc2, gainNode };
}
A pure sine wave sounds artificial. Real instruments produce harmonics (integer multiples of the fundamental frequency). By adding the first harmonic (2x frequency), we create a richer, more piano-like tone.
ADSR (Attack, Decay, Sustain, Release) shapes how the sound evolves over time - critical for realistic instrument sounds.
// Piano ADSR envelope
const now = audioCtx.currentTime;
const volume = masterVolume * velocity;
// Start at zero (no click)
gainNode.gain.setValueAtTime(0, now);
// ATTACK: Quick rise to peak (10ms)
// Piano has a percussive attack
gainNode.gain.linearRampToValueAtTime(
volume * 0.8, // Peak amplitude
now + 0.01 // 10ms attack
);
// DECAY: Fall to sustain level (300ms)
// Exponential decay sounds more natural
gainNode.gain.exponentialRampToValueAtTime(
volume * 0.3, // Sustain level (30% of peak)
now + 0.3 // 300ms decay
);
// RELEASE: When key is released
function releaseNote(gainNode) {
const now = audioCtx.currentTime;
// Cancel any scheduled changes
gainNode.gain.cancelScheduledValues(now);
// Get current value
gainNode.gain.setValueAtTime(
gainNode.gain.value,
now
);
// Fade out over 300ms
gainNode.gain.exponentialRampToValueAtTime(
0.001, // Near zero (can't go to 0)
now + 0.3
);
}
exponentialRampToValueAtTime can't target 0 (undefined in log scale). Use a very small value like 0.001 instead.
We map computer keys to piano notes using the classic piano keyboard layout pattern used by DAWs.
// Map keyboard keys to notes
// White keys: A S D F G H J K L ; '
// Black keys: W E T Y U O P
const keyboardMap = {
// Lower octave
'a': 'C', // C
'w': 'C#', // C#
's': 'D', // D
'e': 'D#', // D#
'd': 'E', // E
'f': 'F', // F
't': 'F#', // F#
'g': 'G', // G
'y': 'G#', // G#
'h': 'A', // A
'u': 'A#', // A#
'j': 'B', // B
// Upper octave
'k': 'C+', // C (next octave)
'o': 'C#+', // C#
'l': 'D+', // D
'p': 'D#+', // D#
';': 'E+', // E
"'": 'F+' // F
};
// Prevent key repeat
const pressedKeys = new Set();
document.addEventListener('keydown', (e) => {
if (e.repeat) return; // Ignore held keys
const key = e.key.toLowerCase();
const note = keyboardMap[key];
if (note && !pressedKeys.has(key)) {
pressedKeys.add(key);
playNote(note);
}
});
document.addEventListener('keyup', (e) => {
const key = e.key.toLowerCase();
pressedKeys.delete(key);
stopNote(keyboardMap[key]);
});
The sustain pedal keeps notes ringing even after keys are released. We track sustained notes separately and release them when the pedal is lifted.
let sustainPedal = false;
const activeOscillators = new Map(); // Currently playing
const sustainedNotes = new Set(); // Held by pedal
function stopNote(note, force = false) {
const noteId = getNoteId(note);
// If sustain is on and not forced, don't stop
if (sustainPedal && !force) {
sustainedNotes.add(noteId);
return; // Let it ring
}
// Actually stop the note
const sound = activeOscillators.get(noteId);
if (sound) {
releaseNote(sound.gainNode);
activeOscillators.delete(noteId);
}
}
// When sustain pedal is released
function releaseSustainedNotes() {
sustainedNotes.forEach(noteId => {
const sound = activeOscillators.get(noteId);
if (sound) {
releaseNote(sound.gainNode);
activeOscillators.delete(noteId);
}
});
sustainedNotes.clear();
}
// Spacebar controls sustain
document.addEventListener('keydown', (e) => {
if (e.key === ' ') {
e.preventDefault();
sustainPedal = true;
}
});
document.addEventListener('keyup', (e) => {
if (e.key === ' ') {
sustainPedal = false;
releaseSustainedNotes();
}
});
On a real piano, the sustain pedal lifts all dampers, allowing strings to resonate freely. Our digital version mimics this by preventing the release envelope until the pedal is lifted.
The AnalyserNode provides real-time frequency data for a visual representation of the sound.
// Setup analyser
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256; // Power of 2, determines resolution
// Connect audio path: source -> masterGain -> analyser -> speakers
masterGain.connect(analyser);
analyser.connect(audioCtx.destination);
// Visualize with CSS bars
function updateVisualizer() {
const bufferLength = analyser.frequencyBinCount; // fftSize / 2
const dataArray = new Uint8Array(bufferLength);
// Get frequency data (0-255 for each bin)
analyser.getByteFrequencyData(dataArray);
const bars = document.querySelectorAll('.viz-bar');
const step = Math.floor(bufferLength / bars.length);
bars.forEach((bar, i) => {
// Sample frequency bins
const value = dataArray[i * step];
// Map to bar height (2px minimum, 50px maximum)
const height = Math.max(2, (value / 255) * 50);
bar.style.height = height + 'px';
});
// Continue animation loop
requestAnimationFrame(updateVisualizer);
}
// Start visualizer
updateVisualizer();
The FFT (Fast Fourier Transform) size determines frequency resolution. A size of 256 gives 128 frequency bins. Lower values update faster but with less detail. Higher values (2048) show more frequencies but update slower.