Skip to content

Commit 29dcb5a

Browse files
committed
Add unmute.js to improve web audio on iOS Safari
1 parent a65d4fe commit 29dcb5a

File tree

5 files changed

+268
-2
lines changed

5 files changed

+268
-2
lines changed

README.md

+5
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,8 @@ If things aren't behaving properly in Safari on iOS:
3737
$ npm install live-server
3838
$ live-server --port=8081 .
3939
```
40+
41+
## Third-party libraries used
42+
1. [Turf.js](https://turfjs.org/) (for GeoJSON processingand geospatial calculations)
43+
2. [Leaflet](https://leafletjs.com/) (provides OpenStreetMap widget)
44+
3. [unmute](https://github.com/swevans/unmute) (improved web audio behavior on iOS)

app/js/audio/sound.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Daniel W. Steinbrook.
22
// with many thanks to ChatGPT
33

4-
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
4+
export const audioContext = new (window.AudioContext || window.webkitAudioContext)();
55

66
// Variables to store the current sound and speech sources
77
let currentSoundSource = null;

app/js/audio/unmute.js

+255
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
"use strict";
2+
/**
3+
* @file unmute.ts
4+
* @author Spencer Evans evans.spencer@gmail.com
5+
*
6+
* unmute is a disgusting hack that helps..
7+
* 1) automatically resume web audio contexts on user interaction
8+
* 2) automatically pause and resume web audio when the page is hidden.
9+
* 3) ios only: web audio play on the media channel rather than the ringer channel
10+
* 4) ios only: disable the media playback widget and airplay when:
11+
*
12+
* WebAudio is automatically resumed by checking context state and resuming whenever possible.
13+
*
14+
* WebAudio pausing is accomplished by watching the page visilibility API. When on iOS, page focus
15+
* is also used to determine if the page is in the foreground because Apple's page vis api implementation is buggy.
16+
*
17+
* iOS Only: Forcing WebAudio onto the media channel (instead of the ringer channel) works by playing
18+
* a short, high-quality, silent html audio track continuously when web audio is playing.
19+
*
20+
* iOS Only: Hiding the media playback widgets on iOS is accomplished by completely nuking the silent
21+
* html audio track whenever the app isn't in the foreground.
22+
*
23+
* iOS detection is done by looking at the user agent for iPhone, iPod, iPad. This detects the phones fine, but
24+
* apple likes to pretend their new iPads are computers (lol right..). Newer iPads are detected by finding
25+
* mac osx in the user agent and then checking for touch support by testing navigator.maxTouchPoints > 0.
26+
*
27+
* This is all really gross and apple should really fix their janky browser.
28+
* This code isn't optimized in any fashion, it is just whipped up to help someone out on stack overflow, its just meant as an example.
29+
*/
30+
/**
31+
* Enables unmute.
32+
* @param context A reference to the web audio context to "unmute".
33+
* @param allowBackgroundPlayback Optional. Default false. Allows audio to continue to play in the background. This is not recommended because it will burn battery and display playback controls on the iOS lockscreen.
34+
* @param forceIOSBehavior Optional. Default false. Forces behavior to that which is on iOS. This *could* be used in the event the iOS detection fails (which it shouldn't). It is strongly recommended NOT to use this.
35+
* @returns An object containing a dispose function which can be used to dispose of the unmute controller.
36+
*/
37+
function unmute(context, allowBackgroundPlayback, forceIOSBehavior) {
38+
if (allowBackgroundPlayback === void 0) { allowBackgroundPlayback = false; }
39+
if (forceIOSBehavior === void 0) { forceIOSBehavior = false; }
40+
//#region Helpers
41+
// Determine the page visibility api
42+
var pageVisibilityAPI;
43+
if (document.hidden !== undefined)
44+
pageVisibilityAPI = { hidden: "hidden", visibilitychange: "visibilitychange" };
45+
else if (document.webkitHidden !== undefined)
46+
pageVisibilityAPI = { hidden: "webkitHidden", visibilitychange: "webkitvisibilitychange" };
47+
else if (document.mozHidden !== undefined)
48+
pageVisibilityAPI = { hidden: "mozHidden", visibilitychange: "mozvisibilitychange" };
49+
else if (document.msHidden !== undefined)
50+
pageVisibilityAPI = { hidden: "msHidden", visibilitychange: "msvisibilitychange" };
51+
// Helpers to add/remove a bunch of event listeners
52+
function addEventListeners(target, events, handler, capture, passive) { for (var i = 0; i < events.length; ++i)
53+
target.addEventListener(events[i], handler, { capture: capture, passive: passive }); }
54+
function removeEventListeners(target, events, handler, capture, passive) { for (var i = 0; i < events.length; ++i)
55+
target.removeEventListener(events[i], handler, { capture: capture, passive: passive }); }
56+
/**
57+
* Helper no-operation function to ignore promises safely
58+
*/
59+
function noop() { }
60+
//#endregion
61+
//#region iOS Detection
62+
var ua = navigator.userAgent.toLowerCase();
63+
var isIOS = (forceIOSBehavior ||
64+
(ua.indexOf("iphone") >= 0 && ua.indexOf("like iphone") < 0) ||
65+
(ua.indexOf("ipad") >= 0 && ua.indexOf("like ipad") < 0) ||
66+
(ua.indexOf("ipod") >= 0 && ua.indexOf("like ipod") < 0) ||
67+
(ua.indexOf("mac os x") >= 0 && navigator.maxTouchPoints > 0) // New ipads show up as macs in user agent, but they have a touch screen
68+
);
69+
//#endregion
70+
//#region Playback Allowed State
71+
/** Indicates if audio should be allowed to play. */
72+
var allowPlayback = true; // Assume page is visible and focused by default
73+
/**
74+
* Updates playback state.
75+
*/
76+
function updatePlaybackState() {
77+
// Check if should be active
78+
var shouldAllowPlayback = (allowBackgroundPlayback || ( // always be active if noPause is indicated
79+
(!pageVisibilityAPI || !document[pageVisibilityAPI.hidden]) && // can be active if no page vis api, or page not hidden
80+
(!isIOS || document.hasFocus()) // if ios, then document must also be focused because their page vis api is buggy
81+
) ? true : false);
82+
// Change state
83+
if (shouldAllowPlayback !== allowPlayback) {
84+
allowPlayback = shouldAllowPlayback;
85+
// Update the channel state
86+
updateChannelState(false);
87+
// The playback allowed state has changed, update the context state to suspend / resume accordingly
88+
updateContextState();
89+
}
90+
}
91+
/**
92+
* Handle visibility api events.
93+
*/
94+
function doc_visChange() {
95+
updatePlaybackState();
96+
}
97+
if (pageVisibilityAPI)
98+
addEventListeners(document, [pageVisibilityAPI.visibilitychange], doc_visChange, true, true);
99+
/**
100+
* Handles blur events (only used on iOS because it doesn't dispatch vis change events properly).
101+
*/
102+
function win_focusChange(evt) {
103+
if (evt && evt.target !== window)
104+
return; // ignore bubbles
105+
updatePlaybackState();
106+
}
107+
if (isIOS)
108+
addEventListeners(window, ["focus", "blur"], win_focusChange, true, true);
109+
//#endregion
110+
//#region WebAudio Context State
111+
/**
112+
* Updates the context state.
113+
* NOTE: apple supports (and poorly at that) the proposed "interrupted" state spec, just ignore that for now.
114+
*/
115+
function updateContextState() {
116+
if (allowPlayback) {
117+
// Want to be running, so try resuming if necessary
118+
if (context.state !== "running" && context.state !== "closed") // do nothing if the context was closed to avoid errors... can't check for the suspended state because of apple's crappy interrupted implementation
119+
{
120+
// Can only resume after a media playback (input) event has occurred
121+
if (hasMediaPlaybackEventOccurred) {
122+
var p = context.resume();
123+
if (p)
124+
p.then(noop, noop).catch(noop);
125+
}
126+
}
127+
}
128+
else {
129+
// Want to be suspended, so try suspending
130+
if (context.state === "running") {
131+
var p = context.suspend();
132+
if (p)
133+
p.then(noop, noop).catch(noop);
134+
}
135+
}
136+
}
137+
/**
138+
* Handles context statechange events.
139+
* @param evt The event.
140+
*/
141+
function context_statechange(evt) {
142+
// Check if the event was already handled since we're listening for it both ways
143+
if (!evt || !evt.unmute_handled) {
144+
// Mark handled
145+
evt.unmute_handled = true;
146+
// The context may have auto changed to some undesired state, so immediately check again if we want to change it
147+
updateContextState();
148+
}
149+
}
150+
addEventListeners(context, ["statechange"], context_statechange, true, true); // NOTE: IIRC some devices don't support the onstatechange event callback, so handle it both ways
151+
if (!context.onstatechange)
152+
context.onstatechange = context_statechange; // NOTE: IIRC older androids don't support the statechange event via addeventlistener, so handle it both ways
153+
//#endregion
154+
//#region HTML Audio Channel State
155+
/** The html audio element that forces web audio playback onto the media channel instead of the ringer channel. */
156+
var channelTag = null;
157+
/**
158+
* A utility function for decompressing the base64 silence string. A poor-mans implementation of huffman decoding.
159+
* @param count The number of times the string is repeated in the string segment.
160+
* @param repeatStr The string to repeat.
161+
* @returns The
162+
*/
163+
function huffman(count, repeatStr) { var e = repeatStr; for (; count > 1; count--)
164+
e += repeatStr; return e; }
165+
/**
166+
* A very short bit of silence to be played with <audio>, which forces AudioContext onto the ringer channel.
167+
* NOTE: The silence MP3 must be high quality, when web audio sounds are played in parallel the web audio sound is mixed to match the bitrate of the html sound.
168+
* This file is 0.01 seconds of silence VBR220-260 Joint Stereo 859B
169+
* The str below is a "compressed" version using poor mans huffman encoding, saves about 0.5kb
170+
*/
171+
var silence = "data:audio/mpeg;base64,//uQx" + huffman(23, "A") + "WGluZwAAAA8AAAACAAACcQCA" + huffman(16, "gICA") + huffman(66, "/") + "8AAABhTEFNRTMuMTAwA8MAAAAAAAAAABQgJAUHQQAB9AAAAnGMHkkI" + huffman(320, "A") + "//sQxAADgnABGiAAQBCqgCRMAAgEAH" + huffman(15, "/") + "7+n/9FTuQsQH//////2NG0jWUGlio5gLQTOtIoeR2WX////X4s9Atb/JRVCbBUpeRUq" + huffman(18, "/") + "9RUi0f2jn/+xDECgPCjAEQAABN4AAANIAAAAQVTEFNRTMuMTAw" + huffman(97, "V") + "Q==";
172+
/**
173+
* Updates the html audio channel control.
174+
* @param isMediaPlaybackEvent Indicates if being called from within a media playback event handler.
175+
*/
176+
function updateChannelState(isMediaPlaybackEvent) {
177+
// Totally unnecessary outside of iOS
178+
if (isIOS) {
179+
if (allowPlayback) {
180+
// We want to be playing back on the media channel, but can only do so from an allowed input event
181+
if (isMediaPlaybackEvent) {
182+
// Create a new channel tag if necessary
183+
if (!channelTag) {
184+
var tmp = document.createElement("div");
185+
tmp.innerHTML = "<audio x-webkit-airplay='deny'></audio>"; // Airplay like controls on other devices, prevents casting of the tag, doesn't work on modern iOS
186+
channelTag = tmp.children.item(0);
187+
channelTag.controls = false;
188+
channelTag.disableRemotePlayback = true; // Airplay like controls on other devices, prevents casting of the tag, doesn't work on modern iOS
189+
channelTag.preload = "auto";
190+
channelTag.src = silence;
191+
channelTag.loop = true;
192+
channelTag.load();
193+
}
194+
// Play the channel tag
195+
if (channelTag.paused) {
196+
var p = channelTag.play();
197+
if (p)
198+
p.then(noop, destroyChannelTag).catch(destroyChannelTag); // If playback fails the tag is pretty much trash and needs to be recreated on next media playback event
199+
}
200+
}
201+
}
202+
else {
203+
// We don't want to be allowing playback at all at the moment, so destroy the channel tag to halt playback and hide those silly iOS media controls
204+
destroyChannelTag();
205+
}
206+
}
207+
}
208+
/**
209+
* Complete unloads / destroys the channel tag.
210+
*/
211+
function destroyChannelTag() {
212+
if (channelTag) {
213+
// Change src to nothing and trigger a load, this is required to actually hide / clear the iOS playback controls
214+
channelTag.src = "about:blank";
215+
channelTag.load();
216+
channelTag = null;
217+
}
218+
}
219+
//#endregion
220+
//#region Input
221+
/** The event types that can trigger media playback. */
222+
var mediaPlaybackEvents = ["click", "contextmenu", "auxclick", "dblclick", "mousedown", "mouseup", "touchend", "keydown", "keyup"];
223+
/** Tracks if a media playback event has occurred */
224+
var hasMediaPlaybackEventOccurred = false;
225+
/**
226+
* Handles events that can begin media playback.
227+
*/
228+
function win_mediaPlaybackEvent() {
229+
hasMediaPlaybackEventOccurred = true;
230+
// This is an opportunity to resume the html audio channel control
231+
updateChannelState(true);
232+
// This is an opportunity to resume the context if paused
233+
updateContextState();
234+
}
235+
addEventListeners(window, mediaPlaybackEvents, win_mediaPlaybackEvent, true, true);
236+
//#endregion
237+
return {
238+
/**
239+
* Disposes unmute, relinquishing all control of media playback.
240+
*/
241+
dispose: function () {
242+
// Stop / clean up the channel tag
243+
destroyChannelTag();
244+
// Remove all listeners
245+
if (pageVisibilityAPI)
246+
removeEventListeners(document, [pageVisibilityAPI.visibilitychange], doc_visChange, true, true);
247+
if (isIOS)
248+
removeEventListeners(window, ["focus", "blur"], win_focusChange, true, true);
249+
removeEventListeners(window, mediaPlaybackEvents, win_mediaPlaybackEvent, true, true);
250+
removeEventListeners(context, ["statechange"], context_statechange, true, true);
251+
if (context.onstatechange === context_statechange)
252+
context.onstatechange = null;
253+
}
254+
};
255+
}

app/js/main.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Daniel W. Steinbrook.
22
// with many thanks to ChatGPT
33

4-
import { createSpatialPlayer, playSpatialSpeech } from './audio/sound.js'
4+
import { audioContext, createSpatialPlayer, playSpatialSpeech } from './audio/sound.js'
55
import { createCalloutAnnouncer } from './audio/callout.js'
66
import cache from './data/cache.js'
77
import { getLocation, watchLocation } from './spatial/geo.js';
@@ -17,6 +17,11 @@ document.addEventListener('DOMContentLoaded', function () {
1717
const announcer = createCalloutAnnouncer(audioQueue, proximityThresholdMeters, true);
1818
const map = createMap('map');
1919

20+
// iOS Safari workaround to allow audio while mute switch is on
21+
let allowBackgroundPlayback = true;
22+
let forceIOSBehavior = false;
23+
unmute(audioContext, allowBackgroundPlayback, forceIOSBehavior);
24+
2025
// Register for updates to location
2126
locationProvider.subscribe(announcer.locationChanged);
2227
locationProvider.subscribe((latitude, longitude, heading) => {

index.html

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
1111
<script src='https://unpkg.com/@turf/turf@6/turf.min.js'></script>
12+
<script src="app/js/audio/unmute.js"></script>
1213
<script src="app/js/main.js" type="module"></script>
1314
</head>
1415
<body>

0 commit comments

Comments
 (0)