aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/zap-sound.js60
1 files changed, 35 insertions, 25 deletions
diff --git a/src/zap-sound.js b/src/zap-sound.js
index c580d47..f25dd5d 100644
--- a/src/zap-sound.js
+++ b/src/zap-sound.js
@@ -4,44 +4,54 @@
// the tap; no network request is needed.
import mp3 from './assets/zap.mp3.base64.txt' with { type: 'text' };
-let context, buffer, loading, playing, sequence = 0;
+let context, buffer, loading, playing, idleTimer, sequence = 0;
-function prepareZap() {
+// Decoding happens in an OfflineAudioContext, which never touches the
+// speaker. A real AudioContext opens the phone's audio output the moment it
+// runs — and in the installed app (where autoplay is allowed) one made at
+// load ran at once, so every open or refocus re-opened the output with an
+// audible pop. So the real one is made on the first tap, and suspended again
+// once the sound has finished or the app goes to the background.
+function decodeZap() {
+ if (loading) return loading;
+ const Offline = window.OfflineAudioContext || window.webkitOfflineAudioContext;
+ if (!Offline) return null;
+ const bytes = Uint8Array.from(atob(mp3.trim()), c => c.charCodeAt(0));
+ loading = new Offline(1, 1, 48000).decodeAudioData(bytes.buffer).then(decoded => (buffer = decoded));
+ loading.catch(() => { loading = null; });
+ return loading;
+}
+function audioContext() {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
if (!AudioCtx) return null;
- if (!context || context.state === 'closed') {
- context = new AudioCtx({ latencyHint: 'interactive' });
- buffer = null;
- loading = null;
- }
- if (!loading) {
- const ctx = context;
- const bytes = Uint8Array.from(atob(mp3.trim()), c => c.charCodeAt(0));
- loading = ctx.decodeAudioData(bytes.buffer).then(decoded => {
- if (context === ctx) buffer = decoded;
- return decoded;
- });
- loading.catch(() => { if (context === ctx) loading = null; });
- }
- return loading;
+ if (!context || context.state === 'closed') context = new AudioCtx({ latencyHint: 'interactive' });
+ return context;
+}
+function suspendSoon(ms = 1500) {
+ clearTimeout(idleTimer);
+ idleTimer = setTimeout(() => { if (!playing && context?.state === 'running') context.suspend().catch(() => {}); }, ms);
}
+if (typeof document !== 'undefined') document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'hidden' && context?.state === 'running') context.suspend().catch(() => {});
+});
-// Decoding is allowed while the audio context is suspended; the tap resumes
-// playback. A browser without audio must still be able to load the wallet.
-try { prepareZap(); } catch {}
+// A browser without audio must still be able to load the wallet.
+try { decodeZap(); } catch {}
// tappedAt: when the finger landed — the caller's clock, since this module
// may still have been loading when it did.
export function playZapSound(tappedAt = performance.now()) {
try {
- const ready = prepareZap();
- if (!ready) return;
- const ctx = context, token = ++sequence;
+ const ready = decodeZap();
+ const ctx = audioContext();
+ if (!ready || !ctx) return;
+ const token = ++sequence;
+ clearTimeout(idleTimer);
if (ctx.state !== 'running') ctx.resume().catch(() => {});
const start = decoded => {
// A very early tap may beat decoding. Never queue an old sound behind
// a later zap or surprise the user after a stalled decoder recovers.
- if (token !== sequence || context !== ctx || performance.now() - tappedAt > 250) return;
+ if (token !== sequence || context !== ctx || performance.now() - tappedAt > 250) { if (token === sequence) suspendSoon(); return; }
if (playing) { playing.stop(); playing.disconnect(); }
const source = ctx.createBufferSource(), gain = ctx.createGain();
source.buffer = decoded;
@@ -50,7 +60,7 @@ export function playZapSound(tappedAt = performance.now()) {
gain.connect(ctx.destination);
source.onended = () => {
source.disconnect(); gain.disconnect();
- if (playing === source) playing = null;
+ if (playing === source) { playing = null; suspendSoon(); }
};
playing = source;
source.start();