Why This Project?
Every livestream has a soundboard. The pro ones cost hundreds of dollars and lock you into specific hardware. This chapter builds one entirely in the browser — no install, no driver, no subscription. You upload audio files, assign keyboard shortcuts, and fire sounds with a keypress during a live show.
The core new concept is the Web Audio API — a low-level browser audio engine that gives you programmatic control over playback, gain, routing, and timing. You'll also learn how to store binary audio data in the browser using base64 encoding via the FileReader API and localStorage.
Web Audio API
An AudioContext is a processing graph. You create AudioBufferSourceNodes from decoded audio data, route them through a GainNode for volume control, and connect to destination (the speakers). Each node is single-use — create a new one per play.
FileReader API
reader.readAsDataURL(file) converts a local audio file into a base64 data URI string. That string can be stored in localStorage, passed to new Audio() for metadata, and decoded by AudioContext.decodeAudioData() for playback.
AudioBufferSourceNode
Created via ctx.createBufferSource(). Set source.buffer to a decoded AudioBuffer, then call source.start(0). Each source can only play once — you must create a new node for every trigger. This is by design.
GainNode (Volume)
ctx.createGain() creates a volume multiplier. Connect it between the source and destination: source → gainNode → destination. Setting gainNode.gain.value to 0–1 controls volume. A master gain node controls all pads at once.
The AI Angle for This Chapter
The Web Audio API has a quirk that catches everyone: AudioBufferSourceNode is single-use. You cannot call source.start() twice on the same node — you must create a new one each time the pad fires. If you ask AI to "make the pad play audio," it may give you code that works once and then silently fails.
Tell AI about the constraint upfront. Prompt: “Write a playPad function using the Web Audio API. Important: AudioBufferSourceNode is single-use — create a new source node on every call, decode the audio buffer fresh each time, and connect source → gainNode → masterGain → destination.” Giving AI the architectural constraint produces correct code on the first pass.”
The entire playback system reduces to: base64 string → fetch → ArrayBuffer → AudioBuffer → SourceNode → GainNode → speakers. Once you see that pipeline clearly, every Web Audio feature is just adding a new node in the chain.
Before You Build — Map the Audio Graph
Draw the signal chain before writing any code:
- What does the data model for a pad look like? At minimum: id, label, key, color, audioData (base64), duration. What else might you need?
- Where does audio actually live? It’s a base64 string in a JS array, persisted to localStorage. What are the size limits of localStorage? (~5MB). What happens if a user loads many long files?
- Draw the audio graph:
SourceNode → PadGain → MasterGain → destination. What does each node do? What would you add between PadGain and MasterGain if you wanted a per-pad EQ? - The AudioContext must be created (or resumed) on a user gesture. Why? What happens if you try to create it on page load?
- How will you handle stopping a pad mid-playback? What state do you need to track?
The audio graph mental model is the hardest part of this chapter. Once it clicks, the code is straightforward.
Build It
Scaffold the layout and pad data model
Two-column layout: sidebar (controls, add pad form, board settings) and main area (pad grid). The pad grid uses auto-fill minmax so it flows without media queries.
Layout: 260px sidebar + flexible main area.
Sidebar contains:
- Add Pad form: label input, key capture input, color picker, file upload
- Board Settings: board name, grid columns select
- Actions: Stop All, Export JSON, Import JSON, Clear Board
- Shortcuts reference
Main: board title, pad grid div, empty state
Bottom: sticky master volume bar with range input + Stop All button
Pad data shape:
{ id, label, key, color, audioData: base64|null, duration: number|null }
Build the FileReader audio loading pipeline
When the user selects a file, read it as a data URL (base64). Store it in a pendingAudioData variable. Also use a temporary <audio> element to read the duration from metadata before the pad is added.
function readFileAsBase64(file) {
return new Promise((res, rej) => {
const reader = new FileReader();
reader.onload = e => res(e.target.result); // data:audio/...;base64,...
reader.onerror = rej;
reader.readAsDataURL(file);
});
}
function getAudioDuration(base64) {
return new Promise(res => {
const audio = new Audio(base64);
audio.addEventListener('loadedmetadata', () => res(audio.duration));
audio.addEventListener('error', () => res(0));
});
}
Build the Web Audio playback engine
Create the AudioContext lazily on first play (required by browser autoplay policy). For each play: fetch the base64 string as an ArrayBuffer, decode it, create a new SourceNode, connect through a GainNode and MasterGain, and start. Track active sources so you can stop them.
async function playPad(id) {
const pad = pads.find(p => p.id === id);
const ctx = getAudioCtx(); // creates AudioContext if needed
// Fetch the base64 data URI as an ArrayBuffer
const response = await fetch(pad.audioData);
const arrayBuf = await response.arrayBuffer();
// Decode into an AudioBuffer
const audioBuf = await ctx.decodeAudioData(arrayBuf);
// Create nodes (must be fresh per play)
const source = ctx.createBufferSource();
source.buffer = audioBuf;
const gain = ctx.createGain();
gain.gain.value = 1;
// Connect: source → gain → masterGain → speakers
source.connect(gain);
gain.connect(masterGain);
source.start(0);
// Track for stop-all
activeSources[id] = { source, gainNode: gain };
source.onended = () => { delete activeSources[id]; updateUI(); };
}
Wire keyboard shortcuts and visual feedback
A single keydown listener maps pressed keys to pad IDs. Guard against firing when the user is typing in an input. Add a .playing CSS class to the active pad and animate a progress bar using the audio duration.
document.addEventListener('keydown', e => {
// Skip if user is typing
if (['INPUT','TEXTAREA'].includes(document.activeElement.tagName)) return;
if (e.key === ' ') { e.preventDefault(); stopAll(); return; }
const pad = pads.find(p => p.key === e.key.toUpperCase());
if (pad && pad.audioData) playPad(pad.id);
});
// Visual feedback on the pad element:
// - Add class 'playing' on start (CSS: scale down, red border)
// - Animate .pad-progress bar: width 0% → 100% over audioBuf.duration seconds
// - Remove 'playing' class and reset bar in source.onended callback
Add localStorage persistence and JSON export/import
Save the entire board state (pad array including base64 audio data, board name, master volume) to localStorage. Export as a downloadable JSON file using the Blob + Object URL pattern from Chapter 05. Import restores the full board from a JSON file.
savePads(): JSON.stringify({ boardName, masterVolume, gridCols, pads })
→ localStorage.setItem('it_ch07_board', ...)
Note: base64 audio data can be large. Wrap in try/catch —
localStorage throws a QuotaExceededError if full.
exportBoard(): same Blob download pattern as Ch05/06
→ downloads as boardname.json
importBoard(file): FileReader reads the JSON text,
JSON.parse restores state, call renderPads() + savePads()
Concepts You Just Used
- AudioContext — the root of the Web Audio graph; must be created or resumed on a user gesture due to browser autoplay policy
- AudioBufferSourceNode — single-use playback node; create a new one on every trigger, never reuse
- GainNode — a volume multiplier in the audio graph; connect in series between source and destination
- decodeAudioData() — async method that converts a raw ArrayBuffer into a playable AudioBuffer
- FileReader.readAsDataURL() — converts a local file into a base64 data URI string for in-memory storage
- fetch() on a data URI — you can
fetch(base64String)and call.arrayBuffer()on the result; a clean way to convert base64 back to binary - localStorage quota — base64 audio is ~33% larger than the raw binary; plan for QuotaExceededError on large files
- source.onended — fires when playback completes naturally; use it to clean up state and reset visual feedback
The Web Audio API is a graph, not a player. Every sound goes through a chain of nodes before reaching your speakers. That architecture is what makes it powerful — you can insert filters, compressors, analysers, and reverb nodes anywhere in the chain. This soundboard only uses GainNodes, but the routing pattern you learned here scales to a full audio production suite.
Reflect
- Why must
AudioBufferSourceNodebe single-use? What would break if you could callstart()twice on the same node? - The audio is stored as base64 in localStorage. What’s the tradeoff vs. storing a File object reference? Why can’t you store the actual File object?
- The AudioContext is created lazily on first user interaction. What happens on Chrome if you try to create it on page load? How would you detect and handle a suspended context?
- Right now all pads play at the same volume. How would you add per-pad volume? Where in the audio graph would the per-pad GainNode live relative to the master GainNode?
- The board exports to JSON including all base64 audio. For a board with 10 clips averaging 30 seconds each, roughly how large would that JSON file be?
You built a real production tool. The concepts — audio graph routing, lazy context creation, single-use nodes — are the same ones used in every DAW, game engine, and streaming platform.
Go Further
🎶 Per-Pad Volume
Add a volume slider to each pad’s edit modal. Store volume in the pad object and set the pad-level GainNode on play. This is a second gain stage before masterGain.
📺 Visualizer
Add an AnalyserNode after masterGain. Use requestAnimationFrame + analyser.getByteFrequencyData() to draw a bar chart on a <canvas> element in the footer.
▶ Loop Mode
Add a loop toggle to each pad. Set source.loop = true before calling source.start(). The pad stays active until you stop it manually — perfect for background music tracks.
🕛 Fade Out
Instead of stopping abruptly, use gainNode.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.5) and then call source.stop() 500ms later for a smooth fade.
📋 Multiple Boards
Add a board selector (like a scene switcher). Store multiple boards in localStorage keyed by name. Switch between them without losing any audio data.
🎵 OBS WebSocket Trigger
Use the OBS WebSocket API to trigger scene changes when specific pads fire. A sound effect and a scene switch at the same time, from one keypress.
You’ve Built 8 Real Tools
From an SVG animation to a live soundboard — all in the browser, no frameworks, no accounts. The next chapter is up to you. What do you want to build?
Back to Hub →