Building Browser Instruments with Web Audio API — Piano, Guitar, and Kalimba
Go deeper on this topic
Web Audio & Sound Programming
Audio synthesis, instrument implementation, and physics simulation references
Article 2 of 3 in this series.
Next reads
Three Books That Shaped My Physics Simulations — The Knowledge Behind 203 Implementations
The physics and math knowledge behind 203 interactive demos came primarily from three books. A concrete look at which chapters informed which implementations.
Audio Programming References — Where Web Audio API Meets Music Theory
Key insights from implementing 11 music components including Piano, Guitar, and Theremin with Web Audio API, plus a curated list of books and resources for audio programming.
Use the topic hub as a map, then continue to the next article in the series. New here? Start at the home hub →
AudioContext Initialization
Web Audio API uses AudioContext as the root to which all nodes connect. Due to browser Autoplay Policy, sound won't play without user interaction:
let audioCtx: AudioContext | null = null
function getAudioContext(): AudioContext {
if (!audioCtx) {
audioCtx = new AudioContext()
}
if (audioCtx.state === 'suspended') {
audioCtx.resume()
}
return audioCtx
}Checking for suspended state is essential. On iOS Safari, sound won't play unless resume() is called on the first tap.
Equal Temperament Frequency Calculation
Each key's frequency on the Piano is calculated using 12-tone equal temperament based on A4 = 440Hz:
function noteToFrequency(note: number): number {
// note: MIDI note number (A4 = 69)
return 440 * Math.pow(2, (note - 69) / 12)
}This single line determines the frequency for all 88 keys. Dividing by 12 = equal geometric division of one octave into 12 notes. Mathematically simple, but the history of music theory behind this insight runs deep.
ADSR Envelope
To create instrument-like sound, volume variation over time (envelope) is essential:
type ADSR = {
attack: number // rise time (seconds)
decay: number // decay time (seconds)
sustain: number // sustain level (0-1)
release: number // decay time after key release (seconds)
}
function applyEnvelope(
gainNode: GainNode,
adsr: ADSR,
startTime: number,
) {
const { attack, decay, sustain, release } = adsr
const g = gainNode.gain
g.setValueAtTime(0, startTime)
g.linearRampToValueAtTime(1, startTime + attack)
g.linearRampToValueAtTime(sustain, startTime + attack + decay)
}
function releaseEnvelope(
gainNode: GainNode,
adsr: ADSR,
releaseTime: number,
) {
const g = gainNode.gain
g.cancelScheduledValues(releaseTime)
g.setValueAtTime(g.value, releaseTime)
g.linearRampToValueAtTime(0, releaseTime + adsr.release)
}Piano uses attack=0.01, decay=0.3, sustain=0.3, release=0.5. Kalimba uses attack=0.005, decay=0.1, sustain=0.0, release=1.5. Same mechanism, but parameters dramatically change the timbre.
Waveform Selection per Instrument
The OscillatorNode's type selects the basic waveform:
| Instrument | Waveform | Reason |
|---|---|---|
| Piano | triangle | Soft but with body |
| Guitar | sawtooth | Rich harmonics, string-like |
| Kalimba | sine | Clear metallic tone |
| WindChimes | sine + detune | Metallic tone with shimmer |
function playNote(freq: number, type: OscillatorType, adsr: ADSR) {
const ctx = getAudioContext()
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = type
osc.frequency.setValueAtTime(freq, ctx.currentTime)
osc.connect(gain)
gain.connect(ctx.destination)
applyEnvelope(gain, adsr, ctx.currentTime)
osc.start(ctx.currentTime)
return { osc, gain }
}Guitar Strum Expression
Guitar doesn't play all 6 strings simultaneously -- it staggers them slightly:
function strum(frequencies: number[], direction: 'down' | 'up') {
const ctx = getAudioContext()
const delay = 0.03 // 30ms delay between strings
const ordered = direction === 'down' ? frequencies : [...frequencies].reverse()
ordered.forEach((freq, i) => {
const startTime = ctx.currentTime + i * delay
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = 'sawtooth'
osc.frequency.setValueAtTime(freq, startTime)
osc.connect(gain)
gain.connect(ctx.destination)
gain.gain.setValueAtTime(0, startTime)
gain.gain.linearRampToValueAtTime(0.3, startTime + 0.01)
gain.gain.exponentialRampToValueAtTime(0.001, startTime + 2)
osc.start(startTime)
osc.stop(startTime + 2)
})
}The 30ms delay creates the "strum" texture. This tiny delay sounds realistic to human ears.
WindChimes Randomness
WindChimes randomly selects notes from a pentatonic scale and plays them at random intervals:
const PENTATONIC = [0, 2, 4, 7, 9] // semitone offsets
function chime(baseNote: number) {
const offset = PENTATONIC[Math.floor(Math.random() * PENTATONIC.length)]
const octave = Math.floor(Math.random() * 2) + 5
const note = baseNote + offset + octave * 12
playNote(noteToFrequency(note), 'sine', {
attack: 0.005,
decay: 0.1,
sustain: 0,
release: 2 + Math.random(),
})
}The pentatonic scale never produces dissonance no matter which notes are played simultaneously. This is why even random generation sounds "pleasant."
Why Use Both use-sound and Web Audio API?
Site-wide UI sound effects (pops, clicks) use use-sound (Howler.js), while instrument components use Web Audio API directly. use-sound is optimized for WAV file playback and isn't suited for real-time frequency control. Right tool for the right job.
Summary: Technologies and Tools Used for Web Audio Instruments
For grasping the full scope of Web Audio API, the books in the toolshelf are useful. Timbre design through oscillator combinations in particular is more efficiently learned systematically from books.