TECH DETAILS

Virtual Piano - Day 19

Advanced Level

Table of Contents

01 / Note Frequencies & Music Theory

In 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)
Equal Temperament

Modern pianos use equal temperament tuning where each semitone ratio is exactly 2^(1/12) = 1.0595. This allows playing in any key.

02 / Piano Sound Synthesis

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

Why Harmonics Matter

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.

03 / ADSR Envelope

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 ); }
Exponential Ramp Limitation

exponentialRampToValueAtTime can't target 0 (undefined in log scale). Use a very small value like 0.001 instead.

04 / Keyboard Mapping

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

05 / Sustain Pedal Logic

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

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.

06 / Audio Visualizer

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

Understanding FFT Size

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.