Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added core
Binary file not shown.
91 changes: 91 additions & 0 deletions src/js/_modules/audio-context.js
Original file line number Diff line number Diff line change
@@ -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,
};

66 changes: 49 additions & 17 deletions src/js/_modules/sound.js
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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
Expand All @@ -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;
Expand Down
25 changes: 14 additions & 11 deletions src/js/posts/polyrhythm.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -31,7 +35,6 @@ const tones = [
1760.000 // A6
];

let AudioContext;
let audioCtx;

function ColorObject(position) {
Expand Down Expand Up @@ -187,6 +190,7 @@ function createCircles() {
}

function ding(position) {
if (!audioCtx || audioCtx.state !== 'running') return;
const frequency = tones[position];

const oscillator = audioCtx.createOscillator();
Expand All @@ -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
}

Expand All @@ -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';
});
}
Expand Down
Loading