diff --git a/core b/core new file mode 100644 index 00000000..1542af83 Binary files /dev/null and b/core differ diff --git a/src/js/_modules/audio-context.js b/src/js/_modules/audio-context.js new file mode 100644 index 00000000..520a0c72 --- /dev/null +++ b/src/js/_modules/audio-context.js @@ -0,0 +1,91 @@ +let sharedContext = null; +let isUnlocked = false; +let unlockInFlight = null; +let globalUnlockInstalled = false; + +function getAudioContext() { + if (sharedContext) return sharedContext; + + const AudioContext = window.AudioContext || window.webkitAudioContext; + if (!AudioContext) return null; + + sharedContext = new AudioContext(); + return sharedContext; +} + +async function unlockAudioContext() { + const ctx = getAudioContext(); + if (!ctx) return null; + + if (isUnlocked) return ctx; + if (unlockInFlight) return unlockInFlight; + + unlockInFlight = (async () => { + // iOS Safari often requires an explicit resume() that is initiated from a user gesture. + try { + if (ctx.state === 'suspended') { + await ctx.resume(); + } + } catch {} + + // "Prime" the audio pipeline with a tiny (silent) buffer. + // This is a common workaround for iOS where resume() alone can be unreliable. + try { + const buffer = ctx.createBuffer(1, 1, 22050); + const source = ctx.createBufferSource(); + source.buffer = buffer; + source.connect(ctx.destination); + source.start(0); + source.stop(0); + source.disconnect(); + } catch {} + + isUnlocked = true; + return ctx; + })(); + + try { + return await unlockInFlight; + } finally { + unlockInFlight = null; + } +} + +function installGlobalAudioUnlock() { + if (globalUnlockInstalled) return; + if (typeof document === 'undefined') return; + + globalUnlockInstalled = true; + + const events = ['touchstart', 'touchend', 'pointerdown', 'mousedown', 'keydown']; + const opts = { passive: true, capture: true }; + + const handler = () => { + // Best-effort; if this runs without a gesture it just won't unlock. + unlockAudioContext(); + events.forEach((evt) => document.removeEventListener(evt, handler, opts)); + globalUnlockInstalled = false; + }; + + events.forEach((evt) => document.addEventListener(evt, handler, opts)); + + // If the page is backgrounded and returned, iOS may suspend audio again. + // Re-arm unlock listeners when we become visible. + document.addEventListener('visibilitychange', () => { + const ctx = getAudioContext(); + if (!ctx) return; + if (document.visibilityState !== 'visible') return; + + if (ctx.state !== 'running') { + isUnlocked = false; + installGlobalAudioUnlock(); + } + }); +} + +module.exports = { + getAudioContext, + unlockAudioContext, + installGlobalAudioUnlock, +}; + diff --git a/src/js/_modules/sound.js b/src/js/_modules/sound.js index 4f1bfb55..2c6fd2d2 100644 --- a/src/js/_modules/sound.js +++ b/src/js/_modules/sound.js @@ -1,18 +1,42 @@ module.exports = function Sound() { - const AudioContext = window.AudioContext || window.webkitAudioContext; - const context = new AudioContext(); - const oscillator = context.createOscillator(); - const gain = context.createGain(); + const { + getAudioContext, + unlockAudioContext, + installGlobalAudioUnlock, + } = require('./audio-context'); + + // Make sure iOS has a chance to unlock audio before any playback attempt. + installGlobalAudioUnlock(); + + let context = null; + let oscillator = null; + let gain = null; let frequency = 20; - // types: Sine Square Triangle Sawtooth - oscillator.type = 'sine'; - oscillator.connect(gain); - gain.gain.value = 0.00001; - setFrequency(frequency); - oscillator.start(); let isPlaying = false; + function ensureInitialized() { + if (context && oscillator && gain) return true; + + context = getAudioContext(); + if (!context) return false; + + oscillator = context.createOscillator(); + gain = context.createGain(); + + // types: Sine Square Triangle Sawtooth + oscillator.type = 'sine'; + oscillator.connect(gain); + gain.gain.value = 0.00001; + oscillator.frequency.setValueAtTime(frequency, context.currentTime); + + // Starting an oscillator before the first user gesture can break audio on iOS. + // We only create + start nodes after a playback attempt (which should be gesture-driven). + oscillator.start(); + + return true; + } + function runLoop() { if (isPlaying) { // console.log(frequency); @@ -25,18 +49,24 @@ module.exports = function Sound() { function start() { if (isPlaying) { return; } + if (!ensureInitialized()) { return; } isPlaying = true; runLoop(); // console.info(gain.gain.value); - context.resume().then(() => { - gain.connect(context.destination); - gain.gain.exponentialRampToValueAtTime( - 1, context.currentTime + 0.04 - ); - }); + // iOS Safari: resume/unlock must be initiated from a user gesture. + unlockAudioContext().then(() => { + try { + gain.connect(context.destination); + } catch {} + gain.gain.exponentialRampToValueAtTime(1, context.currentTime + 0.04); + }).catch(() => {}); } function stop() { + if (!context || !gain) { + isPlaying = false; + return; + } // console.info(gain.gain.value); gain.gain.exponentialRampToValueAtTime( 0.00001, context.currentTime + 0.04 @@ -51,7 +81,9 @@ module.exports = function Sound() { function setFrequency(newFrequency) { frequency = newFrequency; - oscillator.frequency.setValueAtTime(frequency, context.currentTime); // value in hertz + if (oscillator && context) { + oscillator.frequency.setValueAtTime(frequency, context.currentTime); // value in hertz + } } this.isPlaying = () => isPlaying; diff --git a/src/js/posts/polyrhythm.js b/src/js/posts/polyrhythm.js index 420986a8..ef454a38 100644 --- a/src/js/posts/polyrhythm.js +++ b/src/js/posts/polyrhythm.js @@ -1,5 +1,9 @@ const ready = require('../_modules/document-ready'); const windowResize = require('../_modules/window-resize'); +const { + unlockAudioContext, + installGlobalAudioUnlock, +} = require('../_modules/audio-context'); let canvasContext; let canvas; @@ -31,7 +35,6 @@ const tones = [ 1760.000 // A6 ]; -let AudioContext; let audioCtx; function ColorObject(position) { @@ -187,6 +190,7 @@ function createCircles() { } function ding(position) { + if (!audioCtx || audioCtx.state !== 'running') return; const frequency = tones[position]; const oscillator = audioCtx.createOscillator(); @@ -205,7 +209,9 @@ function ding(position) { gainNode.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 2); setTimeout(() => { - oscillator.stop(); + try { oscillator.stop(); } catch {} + try { oscillator.disconnect(); } catch {} + try { gainNode.disconnect(); } catch {} }, 2100); // 2 seconds fade-out + 100ms buffer } @@ -214,16 +220,13 @@ function clear() { } function setUpEvents() { - playButton.addEventListener('click', () => { - AudioContext = window.AudioContext || window.webkitAudioContext; - audioCtx = new AudioContext(); - draw(); - // audioCtx.resume().then(() => { - // console.log('Playback resumed successfully'); - // }).catch(error => { - // document.querySelector('debug').innerText += `\n\n${error.toString()}`; - // }); + // Ensure iOS has a user-gesture path to unlock audio. + installGlobalAudioUnlock(); + playButton.addEventListener('click', async () => { + audioCtx = await unlockAudioContext(); + if (!audioCtx) return; + draw(); playButton.style.display = 'none'; }); }