diff options
| author | Adam Soltys <[email protected]> | 2026-10-07 21:42:58 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-07 21:42:58 -0700 |
| commit | 9bc604bdd0359cae6c8f43bc6a6ad8b96bff4433 (patch) | |
| tree | c29a95ffd85497c5c9b4e4597670069fc445ad5c | |
| parent | 654e489cb438c62c09d291cc781707323914f8f1 (diff) | |
| download | coinosv3-9bc604bdd0359cae6c8f43bc6a6ad8b96bff4433.tar.gz coinosv3-9bc604bdd0359cae6c8f43bc6a6ad8b96bff4433.zip | |
No speaker pop on opening the app: the zap sound decodes in an OfflineAudioContext and makes its real AudioContext on the first tap (one made at load ran at once in the installed app, re-opening the phone's audio output on every open/refocus), suspended again after the sound and when the app is hidden
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PCGjEEMD23CweKrefbTDJp
| -rw-r--r-- | src/zap-sound.js | 60 | ||||
| -rw-r--r-- | tools/zap-feedback-test.js | 7 |
2 files changed, 42 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(); diff --git a/tools/zap-feedback-test.js b/tools/zap-feedback-test.js index 3fdd3bc..adc7663 100644 --- a/tools/zap-feedback-test.js +++ b/tools/zap-feedback-test.js @@ -110,6 +110,10 @@ try { await page.setViewport({ width: 390, height: 844 }); await page.goto(server.url.href); await page.waitForSelector('.note-zap'); + await pause(600); + // a live AudioContext opens the phone's speaker path (a pop on every open + // or refocus of the installed app) — none may exist before a tap + check('no audio context is opened before the first tap', await page.evaluate(() => zapAudio.context === null)); await page.click('.note-zap'); let s = await state(); check('tap starts effect and adds amount before payment settles', s.effects === 1 && s.tally === '121' && s.flying && s.calls === 1); @@ -204,6 +208,9 @@ try { await page.click('.note-zap'); await pause(100); check('with the sound off a tap still strikes and counts, silently', await page.evaluate((n, c) => zapAudio.starts === n && test.calls === c + 2 && !!document.querySelector('.zap-fx'), starts, calls)); await page.evaluate(() => test.finishArk(true)); await pause(100); + // the last sound was a while ago: the speaker path is closed again + await page.waitForFunction(() => zapAudio.context.state === 'suspended', { timeout: 6000 }).catch(() => {}); + check('the audio context is suspended again once the sound has played', await page.evaluate(() => zapAudio.context.state === 'suspended')); const silentPage = await browser.newPage(); silentPage.on('pageerror', e => errors.push(e.message)); await silentPage.evaluateOnNewDocument(() => { |
