goldfire / howler.js

Javascript audio library for the modern web.
https://howlerjs.com
MIT License
23.87k stars 2.23k forks source link

Playing then pausing audio before AudioContext resumed, then resuming AudioContext while telling audio to play causes duplicate sound to play #950

Open redpandabytes opened 6 years ago

redpandabytes commented 6 years ago

Wow that title is a mouthful.. my colleague and I have been diagnosing a bug we believe to be caused by the new way Google Chrome handles audio unlocking. I could not reproduce this bug in Chrome Version 66.0.3359.117, but did manage to reproduce it in Chrome Canary Version 68.0.3417.0.

Steps to reproduce:

  1. Run the broken code example below. We are using an audio sprite, I am not sure if this affects whether or not the bug will occur.
  2. Open the bug page in Canary Version 68.0.3417.0, ensuring the developer tools are closed (no idea why this stops the bug occurring..)
  3. The page needs to fully load without any user interaction, so that the AudioContext does not get unlocked.
  4. As soon as the page loads, Howler will be told to loop audio and then immediately pause this audio.
  5. A listener is added onto the page when focus is gained to replay the sound, and pause onblur.
  6. If the user loses focus on the webpage then clicks back on to the website, this will both unlock the web audio context, and the paused sound should be resumed.

Note: To make the issue more apparent, click on and off the page after you have got the context unlocked

Expected Behaviour:

Only one instance of the sound should be played at a time (behaviour found in older versions of Google Chrome).

Actual Behaviour:

Running the broken code causes two sounds to play with one Howler ID, one of which seems to function normally, with the other one being completely unpredictable. If you click on and off the page again to pause the sound, you’ll see the issue occurring, check the console log to verify there is only one sound playing inside Howler.

Why is this a problem:

If a user is loading a game which uses Howler, and they lose focus on the page during load, and then come back to the page at a later state, there is a good chance that Howler may produce duplicate sounds with unpredictable behaviour. If the user does not have focus on the game, our game engine for instance, will pause the game (including sounds) as soon as it finishes loading.

Example Code:

<html>

<head>
    <title>Howler Bug Test</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/howler/2.0.9/howler.min.js" integrity="sha256-10Rik9+zjesWD7fFnZ2zo+PP+WyN9AtJ8PMvj4uuhV4="
        crossorigin="anonymous"></script>
    <script>

        // Create and play Howl
        var sound = new Howl({
            src: ['output.mp3'],
            sprite: {
                "background": [
                    0,
                    3144.013605442177
                ]
            },
        });

        let soundID = sound.play("background");
        sound.loop(true, soundID);
        sound.pause(soundID);

        // Event Listeners
        window.onblur = (e) => {
            sound.pause(soundID);
        }

        window.onfocus = (e) => {
            sound.play(soundID);
        }

        // Log howler state ever 3s
        setInterval(() => {
            console.log(Howler._howls);
        }, 3000);

        // Put status visibly on page
        var updateInfo = () => {
            requestAnimationFrame(updateInfo);

            document.getElementById("contextinfo").innerHTML = "Context status: " + Howler.ctx.state;
            document.getElementById("audiostate").innerHTML = "Paused: " + sound._sounds[0]._paused;
        }

        requestAnimationFrame(updateInfo);
    </script>
</head>

<body>
    Howler bug
    <div id="contextinfo">

    </div>
    <div id="howlerinfo">
        <div id="audiostate">

        </div>
    </div>
</body>

</html>

Link to output.mp3: https://drive.google.com/file/d/17LQAjblNvl6J6iHcX5xl_myhxk2LWCs_/view?usp=sharing

goldfire commented 6 years ago

Thanks for the detailed report and test case! I'm testing this right now to see what I can find out.

goldfire commented 6 years ago

Well, your assumption that this is due to the autoplay change in Chrome is correct. The real issue is that there doesn't seem to be a way to detect this (I'm hoping I'm wrong about that, still digging). A warning gets thrown in the console, but the promise on AudioContext.resume never returns with then or catch, so the play call is just left sitting on a listener. This is why it plays twice, because once you unlock it, the original play gets called with the pending resume event. It seems like there needs to be a way to detect that the audio is locked to be able to correctly fix this. Any ideas?

redpandabytes commented 6 years ago

Just had a quick look and couldn't figure out any proper way to detect it 😢 I took a look at the Howler source, but it's new to me, so it'll probably take a while to get familiar with it.

Really random idea, is there a possibility of adding a lock to a given ID, so that even if an event fires twice like in Chrome's case, Howler will ignore the request if it's already marked as being dealt with?

jsysnowy commented 6 years ago

I've looked into this, made an array which stores which ID's are playing, and if an ID is told to play which already exists in this array, it blocks it from playing...

It seems to resolve the issue somewhat, however it's kinda messy and i'm not too confident with the structure of this code base. At least this does catch the issue, and prevents it by just calling return; if it detects the issue.

I've done this in a forked version of Howler, i'll link this, and if you want me to send a PR so you can review this and probably refactor this into a much nicer implementation ... just hope it's somewhat helpful. (Not properly tested this in case it has any further implications which i wasn't aware of, it literally just catches this issue and returns ;D ) .. also left all my console logs in just to track what's happening.

Hope this can help out ^-^

link: https://github.com/jsysnowy/howler.js/tree/Chrome_66_duplicate_audio

jsysnowy commented 6 years ago

Just an update; managed to reproduce this issue on iOS Safari, under similar circumstances with audio context. The above seems to fix that too; fortunately.

TalkingGoose commented 5 years ago

I have run into this issue myself, and through a little bit of experimentation seemed to have been able to resolve the issue with a one line change.

Due to the updates made after this issue was initially raised, there has been the addition of the "_playLock" variable.

By changing this line to } else if (!self._playLock) { the resume only causes a single play attempt that is resolved correctly.

I have tested this on desktop Chrome, as well as iOS Chrome, Safari & Firefox.

Further testing may be required, but I personally cannot find issues caused by making this change.

A pull request can be made if deemed necessary.

MathieuBranchaud commented 1 year ago

Issue still occur in 2023 on desktop using Firefox and on mobile with at least Chrome and maybe other.

If your application play some music once it finish loading and you pause and resume it when focus is lost/gain, you should have the same issue when focus is lost and regain before any gesture was received to unlock the AudioContext. (ex: switching tab)

TalkingGoose investigation and fix still apply, (adding a check to avoid registering a second callback to the resume event in the play function.

If you're like me and don't want to use a custom version of Howler, you might be able to update your pause and resume system to let Howler handle Howl objects when the AudioContext is not running and only call pause and play on focus and blur when Howler.ctx.state === "running"