@@ -24,8 +24,8 @@ type Note = string | number;
2424const MUTE_KEY = "pbctf_sound_muted" ;
2525
2626class 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