Generalize mobile audio unlock#5462
Conversation
… instructions show.
|
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. |
|
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);
}; |
There was a problem hiding this comment.
Should this maybe pass in the diagnostics div ID?
There was a problem hiding this comment.
Or maybe I should tear out the diagnostics stuff altogether? I'm not sure how useful it is long-term. What do you think?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Yeah, it's in source control. Just pushed a change to remove the diagnostic.
|
LGTM! One small nit Q |
|
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.
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. |
|
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. |
|
Investigating the autoplay issue I ran across these two terrifying statements. The first is Microsoft admitting that autoplay is weird on Windows Phone:
The second is Mozilla admitting that autoplay completely ignores its value:
Wat. |
|
Wonder if/how howler/soundjs handle this in their audio tag fallback? |
|
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.jshowler.js seems to be more meticulous with its HTML5 audio. During setup, they call 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
Maybe this stabilizes their implementation? SoundJSSoundJS 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 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 😦 . |
There was a problem hiding this comment.
nit - you can 1-line this by moving the right hand of 202 to hasSongInLevel
There was a problem hiding this comment.
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!
|
LGTM after keypress / keydown issue gets addressed |
…lock Generalize mobile audio unlock
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)












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
touchendevent, not atouchstart.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
touchstartto bindmousedownandtouchendhandlers 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 expressions.context.state == "running".howler.js only binds
touchendand 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 asetTimeout(fn, 0)to defer a single tick, then checking the AudioBufferSourceNode using the expressionsource.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
isAudioUnlockedstate 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 bothmousedownandtouchendas 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:
We checked the new audio unlock system across the following browsers:
Here's our test matrix doc (no external access, sorry).