← Blog
Web Audio APITypeScriptSoundInteractive

Building Browser Instruments with Web Audio API — Piano, Guitar, and Kalimba

Updated May 27, 20269 min read

Go deeper on this topic

Web Audio & Sound Programming

Audio synthesis, instrument implementation, and physics simulation references

Article 2 of 3 in this series.

View topic page

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.