Web Audio API Memory Leaks on Mobile Platforms
Asked Answered
M

2

15

I am working on an application that will be using Audio quite heavily and I am in the research stages of deciding whether to use Web Audio API on devices that can support it. I have put together a very simple test bed that loads an MP3 sprite file (~600kB in size), has a play and pause button and also a destroy button, which should in theory allow GC reclaim the memory used by the Web Audio API implementation. However, after loading and destroying ~5 times iOS crashes due to an out of memory exception.

I have profiled MobileSafari in XCode Instruments and indeed MobileSafari continually eats up memory. Furthermore the 600kb MP3 turns out to use ~80-90MB of memory when decoded.

My question is - When decoding audio data using Web Audio API, why is the memory usage so big and also why is the memory never reclaimed? From my understanding the decoding is an async operation for the browser and so presumably happens on a separate thread? Is it possible the browsers separate thread is never releasing the memory used during decoding?

My code is below, any help/explanation is greatly appreciated:

<!DOCTYPE html>
<html>
<head lang="en">
    <meta charset="UTF-8">
    <title>Web Audio Playground</title>
</head>
<body>
<button id="load">
    Load
</button>
<button id="play">
    Play
</button>
<button id="pause">
    Pause
</button>
<button id="destroy">
    Destroy
</button>
<script type="application/javascript">
    (function () {
        window.AudioContext = window.AudioContext || window.webkitAudioContext;

        var loadButton = document.getElementById('load'),
                playButton = document.getElementById('play'),
                pauseButton = document.getElementById('pause'),
                destroyButton = document.getElementById('destroy'),
                audioContext = new window.AudioContext(),
                soundBuffer = null,
                soundSource = null;

        loadButton.addEventListener('click', function () {
            var request = new XMLHttpRequest();
            request.open('GET', 'live-sprite.mp3', true);
            request.responseType = 'arraybuffer';

            // Decode asynchronously
            request.onload = function () {
                audioContext.decodeAudioData(request.response, function (buffer) {
                    soundBuffer = buffer;
                });
            };
            request.send();
        });

        playButton.addEventListener('click', function () {
            soundSource = audioContext.createBufferSource();
            soundSource.buffer = soundBuffer;
            soundSource.connect(audioContext.destination);
            soundSource.start(0);
        });

        pauseButton.addEventListener('click', function () {
            if (soundSource) {
                soundSource.stop(0);
            }
        });

        destroyButton.addEventListener('click', function () {
            if (soundSource) {
                soundSource.disconnect(0);
                soundSource = null;
                soundBuffer = null;
                alert('destroyed');
            }
        });
    })();

</script>
</body>
</html>
Mut answered 9/6, 2014 at 11:50 Comment(6)
May not be related, but I would also not create an AudioContext for every load. Browsers also have a limit to the total number of AudioContexts you are allowed to create (afaik it's 6 on Chrome on OSX).Telepathy
Thanks for the comment but I'm not creating a new AudioContext every time I load a file, just once when the page loads. In my real world scenario I will only be using one AudioContext through the application and just creating/destroying BufferSources.Mut
Ah yes. My mistake. I read the indented variable initialization block as a function call.Telepathy
Hi Shepless, I'm wondering if you ever solved this question? And if so how? I'm encountering the same problem. I'm using the webkitOfflineAudioContext - loading up some buffers into it, writing them to a WAV file, but once the file is written the heap size remains huge. Can only perform the operation about 4/5 times and then Crash.Stewpan
Hi Shepless, I'm wondering did you ever find a solution to this. Sorry for asking again, but you are the only person I can find online that describes EXACTLY what I have experienced. My app is a one page app, so I've hacked a solution that the app opens a new pages to perform this operation and then closes and returns to the previous page on the operations completion. The only thing that is releasing the memory is closing the page. But this is having an impact on the User experience and causing a lag....Stewpan
Hi @LindaKeating. Unfortunately not. We have resorted to using HTML5 audio instead. If we ever know/find out how to resolve this we will be making the switch. As of now we have invested too much time and effort into trying to resolve something that I just don't think we can control(see my comment on the answer below). Sorry.Mut
D
5

I made post on SoundJS issue tracker about this, but I'll reiterate it here for anyone looking:

It seems that simply disconnecting and dereferencing the AudioBufferSourceNode object on iOS Safari isn't enough; you need to manually clear out the reference to its buffer, or the buffer itself leaks. (This implies the AudioBufferSourceNode obj itself leaks, but we didn't see this as a practical limit in our project.)

Unfortunately to do this, a 1-sample long scratch buffer needs to get created, as assigning to null will cause an exception. The statement must be try-catch wrapped, too, as Chrome/FF will throw when .buffer is reassigned at any time.

The solution that worked was:

var ctx = new AudioContext(),
    scratchBuffer = ctx.createBuffer(1, 1, 22050);

class WebAudioAdapter extends AudioAdapter {
    close() {
        if( this.__src ) {
            this.__src.onended = null;
            this.__src.disconnect(0);
            try { this.__src.buffer = scratchBuffer; } catch(e) {}
            this.__src = null;
        }
    }
}

Hope this helps y'all too!

Dannica answered 14/9, 2015 at 15:53 Comment(2)
Which iOS versions does this affect? Still need in 9?Abroad
I found setting buffer = null was enough. Doesn't seem to throw an error on the latest Chrome / Firefox I tested.Filch
C
3

The memory is large because the Web Audio API decodes your small MP3 into 32-bit LPCM – which will give you something on the order of 10MB per minute per channel.

So a 4 minute stereo MP3 would end up being something like 80MB.

This memory can't be reclaimed for as long as your application is holding on to the decoded AudioBuffer. So as long as you have a reference to it (in your case, soundBuffer), that memory can't be released. If it was, you couldn't play back the audio.

Cosmonautics answered 26/8, 2014 at 15:25 Comment(3)
Hi Kevin, I see that you commented before when I asked the same question. Your answer above still doesn't make complete sense to me. My understanding is that the destroyButton above is removing the reference to the soundBuffer once the audio has been loaded and played, so theoretically all the memory the buffers were using should be reclaimed at that point? I've experienced the exact same as Shepless reports above. Each time a sound is loaded, played then the buffer destroyed, the memory for that operation is not fully reclaimed. I'm using this technology on IOS and have also profiled itStewpan
on Safari the Shepless has. I'm at my wits end with this one. If anybody knows the reason why the memory is not fully released PLEASE let me know.Stewpan
@LindaKeating Same here! I just don't understand why GC doesn't ever clean it up. The only explanation I can think of is that the separate thread that decodes the audio (that we have no influence over) is still holding onto the memory.Mut

© 2022 - 2024 — McMap. All rights reserved.