Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Web Audio API - Playing synchronized sounds

I'm trying to find out what the best way to play synchronised Audio tracks through the Web Audio API is. What i'm trying to achieve is to play multiple .wav files at once with as little delay in synchronisation of the audio tracks as possible.

The only way i've found to play multiple audio tracks at the same time is to create multiple audio tracks and loop through them in a for loop. The issue with this is that there's a tiny amount of delay between the loops. The delay is only a couple of milliseconds usually depending on the users machine however when I have something like 30 audio tracks that need to start at the same time and my loop has to loop over 30 tracks and call source.start() on each of them, there is a noticeable delay by the time the loop starts the 30th track.

As I need the tracks to play as on time as possible, I was wondering if there was perhaps another solution. Maybe for example where via the Web Audio API you could load in multiple sources and then have a native global event that would start all those tracks simultaneously.

Here is some code that shows the issue:

const audioBuffer1 = '...'; // Some decoded audio buffer
const audioBuffer2 = '...'; // some other decoded audio buffer
const audioBuffer3 = '...'; // and another audio buffer

const arrayOfAudioBuffers = [audioBuffer1, audioBuffer2, audioBuffer3];    
const context = new AudioContext();

function play(audioBuffer) {
  const source = context.createBufferSource();
  source.buffer = audioBuffer;
  source.connect(context.destination);
  source.start();
}

for (let i = 0; i < arrayOfAudioBuffers.length; i++) {
  // every time this loops the play function is 
  // called around 2 milliseconds after the previous
  // one causing sounds to get slightly out of sync
  play(arrayOfAudioBuffers[i]);
}

An example of an app that uses multiple track sources and manages to keep good synchronisation is Splice Beatmaker. I've explored a few libraries such as Howler and Tone but they appear to use the loop approach I believe.

Would love to hear any suggestions as to how to tackle this issue

like image 286
red house 87 Avatar asked Mar 08 '26 08:03

red house 87


2 Answers

Due to browsers protecting against fingerprinting and timing attacks, timing precision under the hood can be reduced or rounded by modern browsers. This would mean source.start(offset) could never be 100% accurate or reliable in your case. What I recommend is mixing down the sources byte by byte then playing back the final mix. Assuming all audio sources should start at the same time, and time till load is flexible the following will work:

Example:

const audioBuffer1 = '...'; // Some decoded audio buffer
const audioBuffer2 = '...'; // some other decoded audio buffer
const audioBuffer3 = '...'; // and another audio buffer

const arrayOfAudioBuffers = [audioBuffer1, audioBuffer2, audioBuffer3];

We'll need to calculate the length of the entire song by obtaining the buffer with the maximum length.

let songLength = 0;

for(let track of arrayOfAudioBuffers){
    if(track.length > songLength){
        songLength = track.length;
    }
}

Next i've created a method that will take in arrayOfAudioBuffers and output a final mixdown.

function mixDown(bufferList, totalLength, numberOfChannels = 2){

    //create a buffer using the totalLength and sampleRate of the first buffer node
    let finalMix = context.createBuffer(numberOfChannels, totalLength, bufferList[0].sampleRate);

    //first loop for buffer list
    for(let i = 0; i < bufferList.length; i++){

           // second loop for each channel ie. left and right   
           for(let channel = 0; channel < numberOfChannels; channel++){

            //here we get a reference to the final mix buffer data
            let buffer = finalMix.getChannelData(channel);

                //last is loop for updating/summing the track buffer with the final mix buffer 
                for(let j = 0; j < bufferList[i].length; j++){
                    buffer[j] += bufferList[i].getChannelData(channel)[j];
                }

           }
    }

    return finalMix;
}

fyi: you can always remove one loop by hard coding the update per each channel.

Now we can use our mixDown function like so:

const mix = context.createBufferSource();
//call our function here
mix.buffer = mixDown(arrayOfAudioBuffers, songLength, 2);

mix.connect(context.destination);

//will playback the entire mixdown
mix.start()

More about web audio precision timing here

Note: We could use OfflineAudioContext to accomplish the same thing but precision is not guaranteed and still relies on looping and calling start() on each individual source.

Hope this helps.

like image 127
KpTheConstructor Avatar answered Mar 09 '26 21:03

KpTheConstructor


You could try applying an offset:

function play(audioBuffer, startTime) {
  const source = context.createBufferSource();
  source.buffer = audioBuffer;
  source.connect(context.destination);
  source.start(startTime);
}

const startTime = context.currentTime + 1.0; // one second in the future

for (let i = 0; i < arrayOfAudioBuffers.length; i++) {
  play(arrayOfAudioBuffers[i], startTime);
}

This code will queue up all sounds to play at the same time, one second in the future. If this works, you can tune down the delay to make the sounds play more immediately, or even calculate the right delay based on the number of tracks (e.g. 2 ms per track * 30 tracks = 60 ms delay)

like image 38
eiko Avatar answered Mar 09 '26 22:03

eiko



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!