Skip to content

Generalize mobile audio unlock#5462

Merged
islemaster merged 13 commits into
stagingfrom
generalize-mobile-audio-unlock
Nov 20, 2015
Merged

Generalize mobile audio unlock#5462
islemaster merged 13 commits into
stagingfrom
generalize-mobile-audio-unlock

Conversation

@islemaster

Copy link
Copy Markdown
Contributor

Correctly unlock audio for mobile devices on first user interaction, site-wide.

Paired with @bcjordan

Background

On iOS devices (and apparently Android devices, and MS Edge) we aren't allowed to play any audio until it is "unlocked" by having a user interaction trigger audio playback. iOS9 introduced a new limitation that this "unlock" must occur on a touchend event, not a touchstart.

Previously we wrote workarounds for this into our Star Wars and Minecraft tutorials, to facilitate the background music in those places. This left audio broken elsewhere on the site, in places where we were only trying to trigger sound effects on touchstart.

Strategy

We generalize the approach used in Star Wars and Minecraft, and move the logic into our core audio module and out of those specific tutorials. Our original unlocking strategy came from http://paulbakaus.com/tutorials/html5/web-audio-on-ios/.

Research

We considered simply integrating another audio library like SoundJS or HowlerJS but once we settled on an unlock behavior that didn't quite match either we decided it was less effort to keep our current audio system. However, we did investigate the unlock strategy of each of these libraries.

SoundJS waits for touchstart to bind mousedown and touchend handlers for unlocking audio. Like our current strategy, it plays a silent sound clip to unlock audio. It then only removes the handlers if the unlock was successful, which it checks on the AudioContext with the expression s.context.state == "running".

howler.js only binds touchend and only if the user agent indicates an iOS device. It plays a silent sound clip, then unbinds the handler if unlock was successful. The unlock success check is achieved first by using a setTimeout(fn, 0) to defer a single tick, then checking the AudioBufferSourceNode using the expression source.playbackState === source.PLAYING_STATE || source.playbackState === source.FINISHED_STATE.

We chose to attempt an audio unlock by playing a silent sound as early as possible, so our sound system would have a correct isAudioUnlocked state from page load. If the unlock fails we bind our unlock events - we'd rather not limit ourselves to iOS user agents, as we know other mobile devices sometimes require unlock as well. We are binding to both mousedown and touchend as SoundJS does (more likely to be friendly to IE on touch) but we've used the same asynchronous unlock check as howler.js as we had inconsistent results with SoundJS's approach in testing. Finally, we only unbind the unlock handler when we're sure unlock has been successful, as both libraries do, which fixes a bug we noticed with unlock failing in a touch-hold or touch-drag scenario.

Testing performed

For each browser we tested four pages:

  • studio.code.org/home/audio_test - To try out our changes quickly we introduced a small audio test page. It attempts to autoplay music on page load. It also has a button that plays sound immediately in response to a touch event, and one that plays sound on a delay after a touch event.
    • Expected on desktop is to hear music on load.
    • Expected on mobile is to hear no sound on page load, but the second button that plays sound after a delay should work, even if it's the first thing you touch.
  • studio.code.org/s/starwars/stage/1/puzzle/2
    • Expected on desktop is to hear music on load (when instructions show)
    • Expected on mobile is to hear music on first touch
  • studio.code.org/s/mc/stage/1/puzzle/2
    • Expected on both platforms is to hear music when the instructions are dismissed.
  • studio.code.org/s/1/level/1
    • Expected on both platforms is to hear sound effects on run.

We checked the new audio unlock system across the following browsers:

  • iOS 7.1 (on a real iPad Mini late-2012)
  • iOS 8.3 (on Browserstack iPhone 6+ simulator)
  • iOS 9.0
  • iOS 9.1
  • Windows Phone (Nokia Lumina 920 Windows Phone 8.1 IEMobile 11.0)
  • Chrome 46 (Android 4.4.4 on HTC One M7)
  • Chrome 46 (Ubuntu 15.10)
  • Firefox 42 (Ubuntu 15.10)
  • IE10 (Microsoft Surface)
  • IE11 (Windows 7)
  • Edge (Windows 10)

Here's our test matrix doc (no external access, sorry).

@islemaster

Copy link
Copy Markdown
Contributor Author

Also, how great is this: While trying to make this big change to our sound system, audio has broken entirely in Chrome 46 on my laptop 😞 . Bug filed with the Chrome team.

@islemaster

Copy link
Copy Markdown
Contributor Author

Update on our deprecated check for whether audio is unlocked: I wrote a little diagnostic and tried running it on several platforms to see if I could reveal which approach would work best. To review:

  • Approach 1: source.playbackState === source.PLAYING_STATE || source.playbackState === source.FINISHED_STATE used by howler.js and our current implementation.
  • Approach 2: s.context.state == "running" used by SoundJS
  • Approach 3: context.currentTime > 0 as suggested by the page reporting that playbackState is deprecated.

Here's what I learned:

Browser onload ontouch Notes
iOS9 Safari ios9_onload ios9_ontouch All three approaches should work.
iOS8.3 Safari ios83_onload ios83_ontouch Approach 2 will not work.
iOS7 Safari ios7_onload ios7_ontouch Approach 2 will not work.
iOS6 Safari ios6_onload ios6_ontouch Approach 2 will not work.
Android Chrome androidchrome_onload Music does not autoplay but audio seems to unlock fine if either button is pressed. Confusing, because approach 1 is giving a false positive, Approach 2 is inconsistent, sometimes gives 'running' and other times gives 'suspended', and Approach 3 appears to work immediately on load.
Android Firefox androidfirefox_onload Like Android Chrome the music does not autoplay but works fine after touch (maybe through no merit of our own). Approach 1 gives false positive, Approach 2 seems broken, Approach 3 sometimes works (may need a timeout even longer than 30ms before checking).
Desktop Chrome chrome_onload Audio is unlocked immediately. Approach 1 is giving a lucky true positive, Approaches 2 and 3 seem valid.
Desktop Firefox firefox_onload Audio is unlocked immediately. Approach 1 is giving a lucky true positive, Approach 2 is inconsistent (sometimes 'running' other times 'suspended'), Approach 3 seems to work if the timeout before checking is increased from 0ms to 30ms.

I was unable to get working Windows phone setup to test with.

On the whole, Approach 1 seems to be valid only for iOS (and who knows for how long, since it's deprecated) while Approach 3 seems the most generally correct it's also a bit finicky and requires the 30ms+ delay to be consistent. It also seems like we might not need any unlocking logic on other platforms - although Android has a similar behavior preventing autoplay on load, it seems to do magic unlocking by itself.

I suggest we first feature-check Approach 1 by seeing if the enums exist and use that if we can (after a 0ms setTimeout). If Approach 1 is not available, we use a 50ms timeout and check using Approach 3.

@islemaster

Copy link
Copy Markdown
Contributor Author

Here's the implementation of the hybrid unlock check (also just pushed, but written here sans diagnostic steps for clarity):

/**
 * Performs an asynchronous check for whether the given source and context
 * actually played audio.  When finished, calls provided callback passing
 * success/failure as a boolean argument.
 * @param {!AudioBufferSourceNode} source
 * @param {!AudioContext} context
 * @param {!function(boolean)} onComplete
 * @private
 */
Sounds.prototype.checkDidSourcePlay_ = function (source, context, onComplete) {
  // Approach 1: Although AudioBufferSourceNode.playbackState is supposedly
  //             deprecated, it's still the most reliable way to check whether
  //             playback occurred on iOS devices through iOS9, and requires
  //             only a 0ms timeout to work.
  //             We feature-check this approach by seeing if the related enums
  //             exist first.
  if (source.PLAYING_STATE !== undefined && source.FINISHED_STATE !== undefined) {
    setTimeout(function () {
      onComplete(source.playbackState === source.PLAYING_STATE ||
          source.playbackState === source.FINISHED_STATE);
    }.bind(this), 0);
    return;
  }

  // Approach 2: Platforms that have removed playbackState can be checked most
  //             reliably with a longer delay and a check against the
  //             AudioContext.currentTime, which should be greater than the
  //             time passed to source.start() (in this case, zero).
  setTimeout(function () {
    onComplete('number' === typeof context.currentTime && context.currentTime > 0);
  }.bind(this), 50);
};

@bcjordan

Copy link
Copy Markdown
Contributor

(Just to clarify, approach #3 in earlier comments is approach #2 in code snippet comment)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this maybe pass in the diagnostics div ID?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or maybe I should tear out the diagnostics stuff altogether? I'm not sure how useful it is long-term. What do you think?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the one hand, might come in handy with new browsers / at least for IE Mobile test tomorrow.

But on the other hand, quick enough to re-add and it'll be in source control. & will make it easier to read through

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it's in source control. Just pushed a change to remove the diagnostic.

@bcjordan

Copy link
Copy Markdown
Contributor

LGTM! One small nit Q

@islemaster

Copy link
Copy Markdown
Contributor Author

Windows Phone (at least the one I tested, a Nokia Lumina 920 Windows Phone 8.1 IEMobile 11.0) is broken in some interesting ways, and not limited to audio.

  • My diagnostic box on the audio_test page doesn't show up at all. I'm pretty sure this is because it's using HTML5 <audio> instead of Web Audio and we have not implemented unlock logic for the fallback player.
  • On all pages, sound effects play as soon as they are loaded, even if we haven't tried to play them yet. This is not an issue in the desktop versions of IE, so it may be a bug in IEMobile. I'll do some research.
  • Audio, and music in particular, took a very long time to load (several seconds on Wi-Fi to my local server over ngrok). It's possible that using HTML5 audio we load the entire file before we begin music playback (whereas Web Audio tells us when enough of the file has been downloaded to expect gapless playback, and we start it right then). When moving quick, this sometimes made it seem like music failed to play when in fact it was still loading.
  • Music tracks often stop before they have played all the way through.
  • Non-audio issues: We aren't blocking navigation gestures or touch-scrolling like we do on iOS and Android. This makes blockly difficult to use (and droplet frankly impossible) as half the time you will get a page scroll or back/forward navigation instead of or in addition to dragging a block.

Overall I'd describe the experience as "painful, but usable." I don't think it should be in scope for this change to fix up the Windows Phone experience, but it might be worth putting some time into at least for the Minecraft tutorial. I'll do some testing against production to make sure the audio issues above are not a regression, and if not I'd like to merge this initial set of mobile audio improvements.

@islemaster

Copy link
Copy Markdown
Contributor Author

Testing the phone against production, I can't get any audio to play at all - so even with all its issues, this change might be an improvement to Windows Phone audio.

@islemaster

Copy link
Copy Markdown
Contributor Author

Investigating the autoplay issue I ran across these two terrifying statements. The first is Microsoft admitting that autoplay is weird on Windows Phone:

"To improve interoperability with other popular browsers, the autoplay property has no effect in Internet Explorer for Windows Phone 8.1 Update."
autoplay attribute - MSDN

The second is Mozilla admitting that autoplay completely ignores its value:

"autoplay
A Boolean attribute; if specified (even if the value is "false"!), the audio will automatically begin playback as soon as it can do so, without waiting for the entire audio file to finish downloading."
<audio> - HTML - MDN

Wat.

@bcjordan

Copy link
Copy Markdown
Contributor

Wonder if/how howler/soundjs handle this in their audio tag fallback?

@islemaster

Copy link
Copy Markdown
Contributor Author

It's possible our approach is very clunky. Here's what we do (abridged):

    var audioElement = new window.Audio(file);
    if (!audioElement || !audioElement.play) {
      return;
    }

    if (!isIE9()) { // Pre-cache audio
      audioElement.play();
      audioElement.pause();
    }

    var eventListener = function () {
      this.onSoundLoaded();
      audioElement.removeEventListener('canplaythrough', eventListener);
    }.bind(this);
    audioElement.addEventListener('canplaythrough', eventListener);

howler.js

howler.js seems to be more meticulous with its HTML5 audio. During setup, they call new Audio() but don't assign it to anything just to test whether audio needs to be disabled entirely. Then, for each sound, in Howl.prototype.load they also call new Audio() (without any arguments) and save it. They also do a lot of manual setup:

        var newNode = new Audio();

        // listen for errors with HTML5 audio (http://dev.w3.org/html5/spec-author-view/spec.html#mediaerror)
        newNode.addEventListener('error', function () {
          if (newNode.error && newNode.error.code === 4) {
            HowlerGlobal.noAudio = true;
          }

          self.on('loaderror', {type: newNode.error ? newNode.error.code : 0});
        }, false);

        // setup the new audio node
        newNode.src = url;
        newNode._pos = 0;
        newNode.preload = 'auto';
        newNode.volume = (Howler._muted) ? 0 : self._volume * Howler.volume();

        var listener = function() {
          // round up the duration when using HTML5 Audio to account for the lower precision
          self._duration = Math.ceil(newNode.duration * 10) / 10;

          // setup a sprite if none is defined
          if (Object.getOwnPropertyNames(self._sprite).length === 0) {
            self._sprite = {_default: [0, self._duration * 1000]};
          }

          if (!self._loaded) {
            self._loaded = true;
            self.on('load');
          }

          if (self._autoplay) {
            self.play();
          }

          // clear the event listener
          newNode.removeEventListener('canplaythrough', listener, false);
        };
        newNode.addEventListener('canplaythrough', listener, false);
        newNode.load();

Note that the explicitly set the src, position, volume, and preload attribute after construction, and also that they call HTMLMediaElement.prototype.load() - a step that we omit.

HTMLMediaElement.load()
Resets the media element and restarts the media resource. Any pending events are discarded. How much media data is fetched is still affected by the preload attribute. This method can be useful for releasing resources after any src attribute and source element descendants have been removed. Otherwise, it is usually unnecessary to use this method, unless required to rescan source element children after dynamic changes.
HTMLMediaElement - MDN

Maybe this stabilizes their implementation?

SoundJS

SoundJS is by far the most elaborate. It reuses a pool of audio tags. New audio tags are created like this:

var tag = document.createElement("audio");
tag.autoplay = false;
tag.preload = "none";
return tag;

Then I think a lot of magic happens elsewhere in the library, but this section looks especially relevant - a kind of "on play, load if not loaded, otherwise play": (note, the _playbackResource and tag here are both the audio tag)

        if (this._playbackResource.readyState !== 4) {
            var tag = this._playbackResource;
            tag.addEventListener(createjs.HTMLAudioPlugin._AUDIO_READY, this._readyHandler, false);
            tag.addEventListener(createjs.HTMLAudioPlugin._AUDIO_STALLED, this._stalledHandler, false);
            tag.preload = "auto"; // This is necessary for Firefox, as it won't ever "load" until this is set.
            tag.load();
            return;
        }

        this._updateVolume();
        this._playbackResource.currentTime = (this._startTime + this._position) * 0.001;
        if (this._audioSpriteStopTime) {
            this._playbackResource.addEventListener(createjs.HTMLAudioPlugin._TIME_UPDATE, this._audioSpriteEndHandler, false);
        } else {
            this._playbackResource.addEventListener(createjs.HTMLAudioPlugin._AUDIO_ENDED, this._endedHandler, false);
            if(this._loop != 0) {
                this._playbackResource.addEventListener(createjs.HTMLAudioPlugin._AUDIO_SEEKED, this._loopHandler, false);
                this._playbackResource.loop = true;
            }
        }

        this._playbackResource.play();

I'm not sure any of this motivates me to update our HTML5 audio approach though 😦 .

Comment thread apps/src/craft/craft.js

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit - you can 1-line this by moving the right hand of 202 to hasSongInLevel

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually prefer this broken out; naming the expression by assigning it to a temporary variable adds semantic to the logic. The transpiler will optimize it out anyway 😁.

(At least in simulator) Handles case where a touch device (eg iPad)
with an attached keyboard is used, and the initial input is pressing
a key to dismiss the instructions dialog.  In this case, audio now
unlocks and music begins playing as expected.

Nice catch Mehal!
@mehalshah

Copy link
Copy Markdown
Contributor

LGTM after keypress / keydown issue gets addressed

islemaster added a commit that referenced this pull request Nov 20, 2015
@islemaster islemaster merged commit 703c04c into staging Nov 20, 2015
@islemaster islemaster deleted the generalize-mobile-audio-unlock branch November 20, 2015 22:17
deploy-code-org added a commit that referenced this pull request Nov 20, 2015
1a5cbc7 Merge pull request #5557 from code-dot-org/revert-revert-proxy (Bjvanminnen)
fbcc9a0 Merge pull request #4946 from code-dot-org/skip-npm-install-if-already-installed (Brad Buchanan)
3de1b51 Comment nvm workaround [ci skip] (Brad Buchanan)
703c04c Merge pull request #5462 from code-dot-org/generalize-mobile-audio-unlock (Brad Buchanan)
2d5cd5e Automatically built. (Continuous Integration)
d367880 Merge pull request #5552 from code-dot-org/delete_first_screen (Mehal Shah)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants