Skip to content

Commit aa2eee3

Browse files
authored
fix: unlock audio context reliably so UI sounds play on deploy (#25)
mouseenter is not a valid user-activation gesture, so a hover before the first click left the AudioContext suspended while the engine latched a "started" flag and never retried — silencing all sounds. Re-check the real context state on every call and add document-level pointerdown/touchstart/ keydown listeners that unlock audio on the first genuine gesture anywhere.
1 parent 78ce341 commit aa2eee3

1 file changed

Lines changed: 52 additions & 15 deletions

File tree

components/landing/hooks/useRetroSound.ts

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ type Note = string | number;
2424
const MUTE_KEY = "pbctf_sound_muted";
2525

2626
class RetroSoundEngine {
27-
private started = false;
28-
private booting = false;
27+
private built = false;
28+
private unlockAttached = false;
2929
private muted = false;
3030

3131
// Monotonic scheduling cursor — Tone throws if two events on a mono synth
@@ -46,9 +46,33 @@ class RetroSoundEngine {
4646
constructor() {
4747
if (typeof window !== "undefined") {
4848
this.muted = window.localStorage.getItem(MUTE_KEY) === "true";
49+
this.attachUnlock();
4950
}
5051
}
5152

53+
/**
54+
* A Web Audio context can only be resumed from a genuine user-activation
55+
* gesture (pointerdown / keydown / touch — note that `mouseenter` does NOT
56+
* count). We listen once for the first such gesture anywhere on the page so
57+
* the context is unlocked before the user reaches an interactive element,
58+
* regardless of whether they hovered first.
59+
*/
60+
private attachUnlock() {
61+
if (this.unlockAttached) return;
62+
this.unlockAttached = true;
63+
const events = ["pointerdown", "touchstart", "keydown"];
64+
const unlock = () => {
65+
this.ensureStarted().then((running) => {
66+
if (running) {
67+
events.forEach((evt) => window.removeEventListener(evt, unlock));
68+
}
69+
});
70+
};
71+
events.forEach((evt) =>
72+
window.addEventListener(evt, unlock, { passive: true })
73+
);
74+
}
75+
5276
/* ---------------- mute handling ---------------- */
5377

5478
isMuted() {
@@ -84,24 +108,37 @@ class RetroSoundEngine {
84108

85109
/* ---------------- engine lifecycle ---------------- */
86110

87-
private async ensureStarted() {
88-
if (this.started) return;
89-
if (this.booting) return;
90-
this.booting = true;
91-
try {
92-
await Tone.start();
111+
/**
112+
* Ensure the audio context is actually *running* (not just "start() was
113+
* called") and the synths are built. Returns whether audio is live.
114+
*
115+
* Crucially we re-check the real context state on every call: a hover or a
116+
* blocked autoplay attempt may leave the context suspended, and we must keep
117+
* retrying on subsequent gestures rather than latching a "started" flag.
118+
*/
119+
private async ensureStarted(): Promise<boolean> {
120+
if (typeof window === "undefined") return false;
121+
122+
if (Tone.getContext().state !== "running") {
123+
try {
124+
await Tone.start();
125+
} catch {
126+
// Resume can reject when not driven by a real gesture — stay silent.
127+
}
128+
}
129+
130+
if (Tone.getContext().state !== "running") {
131+
return false; // still locked; wait for a genuine user gesture
132+
}
133+
134+
if (!this.built) {
135+
this.built = true; // set before building (build() is synchronous)
93136
this.build();
94-
this.started = true;
95-
} catch {
96-
// Audio unlock can fail outside a gesture; stay silent rather than throw.
97-
} finally {
98-
this.booting = false;
99137
}
138+
return true;
100139
}
101140

102141
private build() {
103-
if (this.master) return;
104-
105142
this.master = new Tone.Volume(-6).toDestination();
106143
this.master.mute = this.muted;
107144

0 commit comments

Comments
 (0)