aboutsummaryrefslogtreecommitdiffstats
path: root/src/zap-sound.js
blob: f25dd5d48faba50ac47b5c1484d7e8f98ac36518 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// The zap sound: "Jacobs Ladder from Side" by Parabolix, trimmed by egomassive
// (freesound.org/s/536741, CC0), transcoded to mp3 for Safari and encoded
// verbatim so hosted and standalone builds both include it. Decode ahead of
// the tap; no network request is needed.
import mp3 from './assets/zap.mp3.base64.txt' with { type: 'text' };

let context, buffer, loading, playing, idleTimer, sequence = 0;

// 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' });
  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(() => {});
});

// 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 = 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) { if (token === sequence) suspendSoon(); return; }
      if (playing) { playing.stop(); playing.disconnect(); }
      const source = ctx.createBufferSource(), gain = ctx.createGain();
      source.buffer = decoded;
      gain.gain.value = .65;
      source.connect(gain);
      gain.connect(ctx.destination);
      source.onended = () => {
        source.disconnect(); gain.disconnect();
        if (playing === source) { playing = null; suspendSoon(); }
      };
      playing = source;
      source.start();
    };
    if (buffer) start(buffer);
    else ready.then(start).catch(() => {});
  } catch {} // Unavailable audio never interrupts a payment or its animation.
}