I’m a sound designer, so… it makes sense that the website that is meant to show my work, well… sounds, right?
This was easier said than done, as doing this was a journey of understanding async functions in JavaScript, understanding web browser´s policies with “auto-play” and basically building a small audio engine from zero. Thinking of this as something similar to what I would do on a game, helped me define an approach that resulted in those super cute stickers on the “home” page triggering the sounds that I believe give so much charm to this page.
The tool that I used to trigger these sounds was the Web Audio API, which is (as you might infer) an API that allows me to play sounds in basically any browser. you can play, pause or stop sounds. You can alter the individual gain of each sound. You can even randomize the pitch… basically the same thing you would do on any audio engine you create for a game.
Let me show you how this works:
Storing the data
We will be working with data, so we need a place to store them.
The first step is to create an object, where we will store all the data for our Audio Manager. (Think of it as creating a package for all of our sound system. This way I will be able to access the functions cleanly, by writing commands like: AudioManager.play("BUTTON_CLICK") or AudioManager.toggleMute().
Here I will declare all of my variables, to have them ready to receive some action.
const AudioManager = {
audioCtx: null, // Will Store the Web Audio Context
masterGain: null, // Will store my master fader
isMuted: false, // Flag to track if the audio is muted
sounds: {}, // Our sounds bank
Downloading the sounds
Before initializing our AudioContext object, we proceed to download the sounds and store them as raw data in the RAM. Having this as an async function, allows the processor to trigger this function in the background, and not having to wait for all assets to load to continue with the rest of the process.
All this, with promise of having all assets downloaded. If this promise fails to be done, it´ll catch the error.
// 1. Downloads the raw sounds (.arrayBuffer) without activating the Audio Context
async startLoadingAssets() {
const assetsToPreload = [
{ key: 'ORCS MUST DIE', url: '/assets/snd/s_omd_click.opus' },
{ key: 'TMNT', url: '/assets/snd/s_tmnt_click.opus' },
{ key: 'KILLER KLOWNS', url: '/assets/snd/s_kkfos_click.opus' },
{ key: 'AL-UMBRA', url: '/assets/snd/s_alumbra_click.opus' },
{ key: 'NEKOME', url: '/assets/snd/s_nekome_click.opus' },
{ key: 'THE SHADOW SYNDICATE', url: '/assets/snd/s_shadow_click.opus' },
{ key: 'REEL', url: '/assets/snd/s_reel_click.opus'},
{ key: 'MENU_IN', url: '/assets/snd/s_toggle_menu_out.opus' },
{ key: 'MENU_OUT', url: '/assets/snd/s_toggle_menu_in.opus' },
{ key: 'BUTTON_CLICK', url: '/assets/snd/s_button_back.opus' },
{ key: 'BUTTON_BACK', url: '/assets/snd/s_button_click.opus' },
{ key: 'BUTTON_TOGGLE', url: '/assets/snd/s_toggle.opus'}
];
// Downloads all of the files in parallel
await Promise.all(assetsToPreload.map(asset => this.preloadBuffer(asset.key, asset.url)));
console.log("Binarios de audio precargados en memoria RAM.");
},
//The web messenger that goes to download each file in parallel.
async preloadBuffer(key, url) {
try {
const response = await fetch(url);
this.sounds[key] = await response.arrayBuffer(); // Guarda el binario crudo
} catch (error) {
console.warn(`No se pudo precargar el buffer: ${url}`, error);
}
},
Initializing the Audio Context
This is the part that initializes the Audio Context, and where we poblate our variables.
We instance the native audio engine regardless of the web browser, and set all the routing and the volume values.
// 2. This is excecuted synchronously with the first click
async init() {
if (this.audioCtx) return;
// Instantiates the audio motor, independently of the web browser
const AudioContext = window.AudioContext || window.webkitAudioContext;
this.audioCtx = new AudioContext();
// Configures and routes the master channel
this.masterGain = this.audioCtx.createGain();
this.masterGain.gain.setValueAtTime(this.isMuted ? 0 : 1, this.audioCtx.currentTime);
this.masterGain.connect(this.audioCtx.destination);
// DTurns the ArrayBuffers into AudioBuffers... ready to play!
for (const key in this.sounds) {
if (this.sounds[key] instanceof ArrayBuffer) {
try {
this.sounds[key] = await this.audioCtx.decodeAudioData(this.sounds[key]);
} catch (err) {
console.error(`Error decodificando el asset: ${key}`, err);
}
}
}
},
I set-up this so we can “cheat” browser´s policies regarding auto-play. By structuring the audio context this way, I can call this init() function instantly after the page registers any valid interaction (like a click), and it will play the sound, because the assets are pre-loaded. Without this, the first click wouldn´t play any sound, regardless if it´s muted or not.
document.body.addEventListener('click', async (e) => {
const target = e.target;
const sticker = target.closest('[data-sound]');
const isBack = target.closest('.back-btn');
const isHamburger = target.closest('.hamburger-menu');
const isUiButton = target.closest('.more-games-btn, .menu-btn, .bio-btn, .port-btn, .blog-btn, .contact-btn, .nav-sub-menu a');
if (!sticker && !isBack && !isHamburger && !isUiButton) {
console.log("No matching ref");
return;
}
if (!AudioManager.audioCtx) {
await AudioManager.init();
}
// ...Rest of the code to trigger AudioManager.play()
});
Finally… time to play()
After all this set-up, we can finally play a sound. The play function has parameters to randomize the pitch and to set up the volume. This is the function that we will be calling after the Audio Context is initialized and running.
I also added the “Mute” function here, where we set the volume everytime depending of the “isMuted” bool.
play(key, volume = 1, randomPitch = false) {
if (this.audioCtx && this.audioCtx.state === 'suspended') {
this.audioCtx.resume();
}
if (!this.audioCtx || !this.sounds[key] || this.sounds[key] instanceof ArrayBuffer) return;
// Crea un nodo de disparo efímero (se autodestruye al terminar el sonido)
const source = this.audioCtx.createBufferSource();
source.buffer = this.sounds[key];
if (randomPitch) {
const minPitch = 0.90;
const maxPitch = 1.10;
const randomFactor = Math.random() * (maxPitch - minPitch) + minPitch;
source.playbackRate.setValueAtTime(randomFactor, this.audioCtx.currentTime);
}
const voiceGain = this.audioCtx.createGain();
voiceGain.gain.setValueAtTime(volume * volume, this.audioCtx.currentTime);
source.connect(voiceGain);
voiceGain.connect(this.masterGain);
source.start(0);
},
toggleMute() {
this.isMuted = !this.isMuted;
if (this.masterGain && this.audioCtx) {
const targetVolume = this.isMuted ? 0 : 1;
this.masterGain.gain.setValueAtTime(targetVolume, this.audioCtx.currentTime);
}
return this.isMuted;
}
};
Creating a simple web audio engine was an experience that was sometimes fun and sometimes frustrating, with a lot of "a-ha!" moments, especially since I learned that its architecture is very similar to what you would do with a game audio engine.
I once heard the great Guy Somberg say “Game Audio Programming is just Game Programming… and Game Programming is just programming” or something like that.
Which means that many of the concepts and logic is transferable from one discipline to the other, and makes me think that as long as you are learning something, you are growing… it doesn´t matter if what you´re learning looks far away from your “main path”.