Search code examples
javascriptaudiohtml5-audionwjs

Audio playback slows down game


I am trying to develop a simple game using nw.js (node.js + chromium page).

<canvas width="1200" height="800" id="main"></canvas>
<script>
var Mouse = {x: 0, y: 0, fire: false};

(async function() {
"use strict";
const reload = 25;
var ireload = 0;
const audioCtx = new AudioContext();
let fire = await fetch('shotgun.mp3');
let bgMusic = await fetch('hard.mp3');
    fire = await fire.arrayBuffer();
    bgMusic = await bgMusic.arrayBuffer();
    
    const bgMdecoded = await audioCtx.decodeAudioData(bgMusic);
    const fireDecoded = await audioCtx.decodeAudioData(fire);
    const bgM = audioCtx.createBufferSource();
    bgM.buffer = bgMdecoded;
    bgM.loop = true;
    bgM.connect(audioCtx.destination)
    bgM.start(0);
    
    let shot = audioCtx.createBufferSource();
    shot.buffer = fireDecoded;
    shot.connect(audioCtx.destination);
    
    document.getElementById('main').onmousedown = function(e) {
        Mouse.x = e.layerX;
        Mouse.y = e.layerY;
        Mouse.fire = true;
    }
    
    function main(tick) {
        var dt =  lastTick - tick;
        lastTick = tick;
        
        ///take fire
        if(--ireload < 0 && Mouse.fire) {
            ireload = reload;
            shot.start(0);
            shot = audioCtx.createBufferSource();
            shot.buffer = fireDecoded;
            shot.connect(audioCtx.destination);
    
            Mouse.fire = false;
        }
    
        /* moving objects, rendering on thread with offscreen canvas */
        requestAnimationFrame(main);
    }   

    let lastTick = performance.now();
    main(lastTick);
})();
</script>

I have stripped code to minimal working example.
The problem is with shooting, everytime I fire (///take fire), the game drops FPS. Exactly the same happens in Kaiido example (https://jsfiddle.net/sLpx6b3v/). This works great, using it in long periods, but playing multiple sounds (the game is shooter) several times, gives framerate drop and after some time GC hiccups.
Less than one year old gaming laptop is dropping 60fps to about 40fps, and about 44fps on Kaidos example.

What could be fixed with sound?

Desired behaviour is no lagging / no gc / no framedrops due to sound. The one in background works well. I will try AudioWorklet, but it is hard to create one and process instantenous sounds (probably another question).


Solution

  • It is possible to reuse buffer, a bit hackish way.
    First create

    const audioCtx = new AudioContext();
    

    then fetch resource as usual:

    let fire = await fetch('shotgun.mp3');
    fire = await fire.arrayBuffer();
    fire = await audioCtx.decodeAudioData(fire);
    
    const shot = audioCtx.createBufferSource();
    shot.buffer = fire;
    shot.loopEnd = 0.00001; //some small value to make it unplayable
    shot.start(0);
    

    Then, during event (mouse down in my case):

    shot.loopEnd = 1; //that restarts sound and plays in a loop.
    

    Next, after it was played, set again

    shot.loopEnd = 0.00001;
    

    In my case, I stop it inside requestAnimationFrame

    <canvas width="1200" height="800" id="main"></canvas>
    <script>
    var Mouse = {x: 0, y: 0, fire: false};
    
    (async function() {
    "use strict";
    const reload = 25;
    var ireload = 0;
    const audioCtx = new AudioContext();
    let fire = await fetch('shotgun.mp3');
    let bgMusic = await fetch('hard.mp3');
        fire = await fire.arrayBuffer();
        bgMusic = await bgMusic.arrayBuffer();
        
        const bgMdecoded = await audioCtx.decodeAudioData(bgMusic);
        const fireDecoded = await audioCtx.decodeAudioData(fire);
        const bgM = audioCtx.createBufferSource();
        bgM.buffer = bgMdecoded;
        bgM.loop = true;
        bgM.connect(audioCtx.destination)
        bgM.start(0);
        
        let shot = audioCtx.createBufferSource();
        shot.buffer = fireDecoded;
        shot.connect(audioCtx.destination);
        shot.loopEnd = 0.00001; //some small value to make it unplayable
        shot.start(0);
        
        document.getElementById('main').onmousedown = function(e) {
            Mouse.x = e.layerX;
            Mouse.y = e.layerY;
            Mouse.fire = true;
        }
        
        function main(tick) {
            var dt =  lastTick - tick;
            lastTick = tick;
            
            ///take fire
            //asuming 60fps, which is true in my case, I stop it after a second
            if(reload < -35) {
                shot.loopEnd = 0.00001;
            }
            
            if(--ireload < 0 && Mouse.fire) {
                ireload = reload;
                shot.loopEnd = 1; //that restarts sound and plays in a loop.
                Mouse.fire = false;
            }
        
            /* moving objects, rendering on thread with offscreen canvas */
            requestAnimationFrame(main);
        }   
    
        let lastTick = performance.now();
        main(lastTick);
    })();
    </script>
    

    A note about GC, it is true that it handles audiobuffers quickly, but I have checked, GC fires only when there are allocations, and memory reallocations. Garbage Collector interupts all script execution, so there is jank, lag.
    I use memory pool in tandem to this trick, allocating pool at initialisation and then only reuse objects, and get literally no GC after second sweep, it runs once, after initialisation and kicks in second time, after optimisation and reduces unused memory. After that, there is no GC at all. Using typed array and workers gives really performant combo, with 60 fps, crisp sound and no lags at all.

    You may think that locking GC is a bad idea. Maybe you are right, but after all, wasting resources only because there is GC doesn't seem like a good idea either.

    After tests, AudioWorklets seem to work as intended, but these are heavy, hard to maintain and consumes a lot of resources and writing processor that simply copies inputs to outputs defies it's purpose. PostMessaging system is really heavy process, and you have to either connect the standard way and recreate buffers, or copy it to Worklet space and manage it via shared arrays and atomic operations manually.

    You may be interested also in: Writeup about WebAudio design where the author share the concerns and gets exactly the same problem, quote

    I know I’m fighting an uphill battle here, but a GC is not what we need during realtime audio playback.

    Keeping a pool of AudioBuffers seems to work, though in my own test app I still see slow growth to 12MB over time before a major GC wipes, according to the Chrome profiler.

    And Writeup about GC, where memory leaks in JavaScript are described. A quote:

    Consider the following scenario:

    1. A sizable set of allocations is performed.
    2. Most of these elements (or all of them) are marked as unreachable (suppose we null a reference pointing to a cache we no longer need).
    3. No further allocations are performed.

    In this scenario, most GCs will not run any further collection passes. In other words, even though there are unreachable references available for collection, these are not claimed by the collector. These are not strictly leaks but still, result in higher-than-usual memory usage.