PageSourceSearch

https://pupwife.com/scripts/effects/siteAudio.js

js pupwife.com collected 2026-09-30 15:05:47 UTC 22,835 bytes, 668 lines download raw bytes

1/**
2 * Optional micro UI sounds — SPA exit/enter whoosh samples + procedural UI tones.
3 */
4(function (global) {
5    'use strict';
6
7    var WHOOSH_EXIT_URL = '/music/internal/SPAWhoosh.mp3';
8    var WHOOSH_ENTER_URL = '/music/internal/SPAWhoosh2.mp3';
9    var WHOOSH_FALLBACK_DELAY_MS = 120;
10    var WHOOSH_ENTER_FALLBACK_DELAY_MS = 90;
11
12    var ctx = null;
13    var bound = false;
14    var navWhooshExitTimer = null;
15    var navWhooshEnterTimer = null;
16    var navWhooshGen = 0;
17    var whooshCache = Object.create(null);
18    var reverbNode = null;
19    var reverbCtx = null;
20    var hoverSoundLastAt = 0;
21    var HOVER_THRUM_MIN_GAP_MS = 55;
22    var HOVER_SOUND_SELECTOR =
23        'a[href], button, input[type="submit"], input[type="button"], input[type="reset"], ' +
24        '[role="button"], [role="link"], [role="tab"], .tool-button, .info-link, .bookings-contact-link';
25
26    function prefersQuiet() {
27        try {
28            if (global.matchMedia && global.matchMedia('(prefers-reduced-motion: reduce)').matches) {
29                return true;
30            }
31        } catch (e) { /* ignore */ }
32        return false;
33    }
34
35    function readUiSoundsSetting() {
36        if (global.SettingsMenu && typeof global.SettingsMenu.getSetting === 'function') {
37            return !!global.SettingsMenu.getSetting('uiSounds');
38        }
39        try {
40            var raw = localStorage.getItem('puppywifey_settings');
41            if (raw) {
42                var parsed = JSON.parse(raw);
43                return !!parsed.uiSounds;
44            }
45        } catch (e) { /* ignore */ }
46        return false;
47    }
48
49    function isEnabled() {
50        if (prefersQuiet()) return false;
51        if (global.PERFORMANCE_MODE_ENABLED) return false;
52        return readUiSoundsSetting();
53    }
54
55    function whooshAssetUrl(url) {
56        if (global.resolveSiteAssetUrl && typeof global.resolveSiteAssetUrl === 'function') {
57            return global.resolveSiteAssetUrl(url);
58        }
59        return url;
60    }
61
62    function getWhooshSlot(url) {
63        if (!whooshCache[url]) {
64            whooshCache[url] = {
65                arrayBuffer: null,
66                decodePromise: null,
67                buffer: null,
68                bufferCtx: null
69            };
70        }
71        return whooshCache[url];
72    }
73
74    function ensureContext() {
75        if (ctx) return ctx;
76        var Ctx = global.AudioContext || global.webkitAudioContext;
77        if (!Ctx) return null;
78        ctx = new Ctx();
79        return ctx;
80    }
81
82    function resumeContext() {
83        var audioCtx = ensureContext();
84        if (!audioCtx) return Promise.resolve(false);
85        if (audioCtx.state === 'running') return Promise.resolve(true);
86        if (audioCtx.state === 'closed') {
87            ctx = null;
88            whooshCache = Object.create(null);
89            reverbNode = null;
90            reverbCtx = null;
91            audioCtx = ensureContext();
92            if (!audioCtx) return Promise.resolve(false);
93        }
94        return audioCtx.resume().then(function () {
95            return audioCtx.state === 'running';
96        }).catch(function () {
97            return false;
98        });
99    }
100
101    function unlockAudio() {
102        return resumeContext();
103    }
104
105    function fetchWhooshArrayBuffer(url) {
106        var slot = getWhooshSlot(url);
107        if (slot.arrayBuffer) return Promise.resolve(slot.arrayBuffer);
108        return fetch(whooshAssetUrl(url), { credentials: 'same-origin' })
109            .then(function (res) {
110                if (!res.ok) throw new Error('HTTP ' + res.status);
111                return res.arrayBuffer();
112            })
113            .then(function (buf) {
114                slot.arrayBuffer = buf;
115                return buf;
116            });
117    }
118
119    function decodeWhooshBuffer(audioCtx, url) {
120        var slot = getWhooshSlot(url);
121        if (slot.buffer && slot.bufferCtx === audioCtx) {
122            return Promise.resolve(slot.buffer);
123        }
124        if (!slot.decodePromise || slot.bufferCtx !== audioCtx) {
125            slot.decodePromise = fetchWhooshArrayBuffer(url)
126                .then(function (buf) {
127                    return audioCtx.decodeAudioData(buf.slice(0));
128                })
129                .then(function (decoded) {
130                    slot.buffer = decoded;
131                    slot.bufferCtx = audioCtx;
132                    return decoded;
133                })
134                .catch(function (err) {
135                    slot.decodePromise = null;
136                    console.warn('[siteAudio] SPA whoosh decode failed', url, err);
137                    return null;
138                });
139        }
140        return slot.decodePromise;
141    }
142
143    function preloadWhoosh() {
144        return resumeContext().then(function (ok) {
145            if (!ok) return null;
146            var audioCtx = ensureContext();
147            if (!audioCtx) return null;
148            return Promise.all([
149                decodeWhooshBuffer(audioCtx, WHOOSH_EXIT_URL),
150                decodeWhooshBuffer(audioCtx, WHOOSH_ENTER_URL)
151            ]);
152        }).catch(function () {
153            return null;
154        });
155    }
156
157    function makeReverbImpulse(audioCtx, seconds, decay) {
158        var rate = audioCtx.sampleRate;
159        var length = Math.floor(rate * seconds);
160        var impulse = audioCtx.createBuffer(2, length, rate);
161        var ch;
162        for (ch = 0; ch < 2; ch++) {
163            var channelData = impulse.getChannelData(ch);
164            var i;
165            for (i = 0; i < length; i++) {
166                channelData[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, decay);
167            }
168        }
169        return impulse;
170    }
171
172    function ensureReverb(audioCtx) {
173        if (reverbNode && reverbCtx === audioCtx) return reverbNode;
174        reverbNode = audioCtx.createConvolver();
175        reverbNode.buffer = makeReverbImpulse(audioCtx, 1.6, 3.1);
176        reverbCtx = audioCtx;
177        return reverbNode;
178    }
179
180    function tone(freq, duration, type, gainValue, when) {
181        var audioCtx = ensureContext();
182        if (!audioCtx) return;
183
184        var start = when != null ? when : audioCtx.currentTime;
185        var osc = audioCtx.createOscillator();
186        var gain = audioCtx.createGain();
187        osc.type = type || 'sine';
188        osc.frequency.setValueAtTime(freq, start);
189        gain.gain.setValueAtTime(0.0001, start);
190        gain.gain.exponentialRampToValueAtTime(Math.max(gainValue || 0.12, 0.0002), start + 0.012);
191        gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
192        osc.connect(gain);
193        gain.connect(audioCtx.destination);
194        osc.start(start);
195        osc.stop(start + duration + 0.03);
196    }
197
198    function playWhenReady(playFn) {
199        if (!isEnabled()) return Promise.resolve();
200        return resumeContext().then(function (ok) {
201            if (!ok) return;
202            playFn();
203        });
204    }
205
206    function playProceduralWhooshFallback(audioCtx, t) {
207        var dur = 0.32;
208        var sampleCount = Math.floor(audioCtx.sampleRate * dur);
209        var buffer = audioCtx.createBuffer(1, sampleCount, audioCtx.sampleRate);
210        var data = buffer.getChannelData(0);
211        var i;
212        for (i = 0;
212 i < sampleCount; i++) {
213            var fade = 1 - (i / sampleCount);
214            data[i] = (Math.random() * 2 - 1) * fade * fade;
215        }
216
217        var noise = audioCtx.createBufferSource();
218        noise.buffer = buffer;
219
220        var filter = audioCtx.createBiquadFilter();
221        filter.type = 'bandpass';
222        filter.Q.value = 0.65;
223        filter.frequency.setValueAtTime(1400, t);
224        filter.frequency.exponentialRampToValueAtTime(280, t + dur);
225
226        var gain = audioCtx.createGain();
227        gain.gain.setValueAtTime(0.0001, t);
228        gain.gain.exponentialRampToValueAtTime(0.12, t + 0.018);
229        gain.gain.exponentialRampToValueAtTime(0.0001, t + dur);
230
231        noise.connect(filter);
232        filter.connect(gain);
233        gain.connect(audioCtx.destination);
234        noise.start(t);
235        noise.stop(t + dur + 0.02);
236    }
237
238    function playWhooshSample(audioCtx, buffer, t) {
239        var source = audioCtx.createBufferSource();
240        source.buffer = buffer;
241        source.playbackRate.value = 0.96;
242
243        var warmth = audioCtx.createBiquadFilter();
244        warmth.type = 'lowpass';
245        warmth.frequency.setValueAtTime(4800, t);
246        warmth.Q.value = 0.55;
247
248        var air = audioCtx.createBiquadFilter();
249        air.type = 'highshelf';
250        air.frequency.value = 2800;
251        air.gain.value = -4.5;
252
253        var reverb = ensureReverb(audioCtx);
254
255        var dryGain = audioCtx.createGain();
256        dryGain.gain.value = 0.5;
257
258        var wetGain = audioCtx.createGain();
259        wetGain.gain.value = 0.42;
260
261        var masterGain = audioCtx.createGain();
262        var tail = Math.min(buffer.duration + 0.35, 1.4);
263        masterGain.gain.setValueAtTime(0.0001, t);
264        masterGain.gain.exponentialRampToValueAtTime(0.2, t + 0.028);
265        masterGain.gain.exponentialRampToValueAtTime(0.07, t + tail * 0.72);
266        masterGain.gain.exponentialRampToValueAtTime(0.0001, t + tail);
267
268        source.connect(warmth);
269        warmth.connect(air);
270        air.connect(dryGain);
271        air.connect(reverb);
272        reverb.connect(wetGain);
273        dryGain.connect(masterGain);
274        wetGain.connect(masterGain);
275        masterGain.connect(audioCtx.destination);
276
277        source.start(t);
278        source.stop(t + tail);
279    }
280
281    function playNavClick() {
282        return playWhenReady(function () {
283            tone(640, 0.07, 'triangle', 0.1);
284        });
285    }
286
287    function whooshExitDelayMs() {
288        if (global.SpaTransitions && global.SpaTransitions.EXIT_DURATION_MS) {
289            return Math.round(global.SpaTransitions.EXIT_DURATION_MS * 0.38);
290        }
291        return WHOOSH_FALLBACK_DELAY_MS;
292    }
293
294    function whooshEnterDelayMs() {
295        if (global.SpaTransitions && global.SpaTransitions.ENTER_PAUSE_MS) {
296            return global.SpaTransitions.ENTER_PAUSE_MS;
297        }
298        return WHOOSH_ENTER_FALLBACK_DELAY_MS;
299    }
300
301    function clearNavWhooshTimer(timerRef) {
302        if (timerRef === 'exit' && navWhooshExitTimer) {
303            clearTimeout(navWhooshExitTimer);
304            navWhooshExitTimer = null;
305            return;
306        }
307        if (timerRef === 'enter' && navWhooshEnterTimer) {
308            clearTimeout(navWhooshEnterTimer);
309            navWhooshEnterTimer = null;
310        }
311    }
312
313    function cancelNavWhoosh() {
314        navWhooshGen += 1;
315        clearNavWhooshTimer('exit');
316        clearNavWhooshTimer('enter');
317    }
318
319    function scheduleNavWhooshExit() {
320        if (!isEnabled()) return;
321        navWhooshGen += 1;
322        var gen = navWhooshGen;
323        clearNavWhooshTimer('exit');
324        navWhooshExitTimer = setTimeout(function () {
325            navWhooshExitTimer = null;
326            if (gen !== navWhooshGen) return;
327            playNavWhooshExit();
328        }, whooshExitDelayMs());
329    }
330
331    function scheduleNavWhooshEnter() {
332        if (!isEnabled()) return;
333        navWhooshGen += 1;
334        var gen = navWhooshGen;
335        clearNavWhooshTimer('enter');
336        navWhooshEnterTimer = setTimeout(function () {
337            navWhooshEnterTimer = null;
338            if (gen !== navWhooshGen) return;
339            playNavWhooshEnter();
340        }, whooshEnterDelayMs());
341    }
342
343    function scheduleNavWhoosh() {
344        scheduleNavWhooshExit();
345    }
346
347    function playWhooshByUrl(url, useFallback) {
348        return playWhenReady(function () {
349            var audioCtx = ensureContext();
350            if (!audioCtx) return;
351
352            var t = audioCtx.currentTime;
353            decodeWhooshBuffer(audioCtx, url).then(function (buffer) {
354                if (!isEnabled()) return;
355                if (!buffer) {
356                    if (useFallback) playProceduralWhooshFallback(audioCtx, t);
357                    return;
358                }
359                playWhooshSample(audioCtx, buffer, audioCtx.currentTime);
360            });
361        });
362    }
363
364    function playNavWhooshExit() {
365        return playWhooshByUrl(WHOOSH_EXIT_URL, true);
366    }
367
368    function playNavWhooshEnter() {
369        return playWhooshByUrl(WHOOSH_ENTER_URL, false);
370    }
371
372    function playNavWhoosh() {
373        return playNavWhooshExit();
374    }
375
376    function playToggle(checked) {
377        return playWhenReady(function () {
378            if (checked) {
379                tone(680, 0.055, 'sine', 0.08);
380            } else {
381                tone(440, 0.055, 'sine', 0.07);
382            }
383        });
384    }
385
386    function playMenuOpen() {
387        return playWhenReady(function () {
388            var audioCtx = ensureContext();
389            if (!audioCtx) return;
390            var start = audioCtx.currentTime;
391            tone(340, 0.065, 'sine', 0.065, start);
392            tone(440, 0.075, 'sine', 0.072, start + 0.042);
393            tone(560, 0.085, 'sine', 0.068, start + 0.088);
394        });
395    }
396
397    function playMenuClose() {
398        return playWhenReady(function () {
399            var audioCtx = ensureContext();
400            if (!audioCtx) return;
401            var start = audioCtx.currentTime;
402            tone(560, 0.06, 'sine', 0.068, start);
403            tone(440, 0.072, 'sine', 0.07, start + 0.038);
404            tone(320, 0.085, 'sine', 0.062, start + 0.082);
405        });
406    }
407
408    function playSoftClick() {
409        return playWhenReady(function () {
410            tone(620, 0.045, 'triangle', 0.075);
411        });
412    }
413
414    function playHoverThrum() {
415        return playWhenReady(function () {
416            var audioCtx = ensureContext();
417            if (!audioCtx) return;
418            var start = audioCtx.currentTime;
419            var dur = 0.068;
420
421            var osc = audioCtx.createOscillator();
422            var gain = audioCtx.createGain();
423            osc.type = 'triangle';
424            osc.frequency.setValueAtTime(420, start);
425            osc.frequency.exponentialRampToValueAtTime(260, start + dur * 0.82);
426            gain.gain.setValueAtTime(0.0001, start);
427            gain.gain.exponentialRampToValueAtTime(0.072, start + 0.011);
428            gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
429            osc.connect(gain);
430            gain.connect(audioCtx.destination);
431            osc.start(start);
432            osc.stop(start + dur + 0.03);
433
434            tone(520, 0.042, 'sine', 0.034, start + 0.006);
435        });
436    }
437
438    function playSelectChange() {
439        return playWhenReady(function () {
440            var audioCtx = ensureContext();
441            if (!audioCtx) return;
442            var start = audioCtx.currentTime;
443            tone(540, 0.05, 'triangle', 0.07, start);
444            tone(720, 0.04, 'triangle', 0.055, start + 0.03);
445        });
446    }
447
448    function playPanelClose() {
449        return playWhenReady(function () {
450            var audioCtx = ensureContext();
451            if (!audioCtx) return;
452            var start = audioCtx.currentTime;
453            tone(560, 0.05, 'sine', 0.07, start);
454            tone(380, 0.07, 'sine', 0.06, start + 0.04);
455        });
456    }
457
458    function shouldSkipSoundTarget(el) {
459        if (!el || !el.closest) return true;
460        if (el.closest('[data-ui-sound="none"]')) return true;
461        if (document.body && document.body.classList.contains('page-chatroom')) return true;
462        if (el.closest('#chatroom-root, .chatroom-shell, [data-chatroom-root]')) return true;
463        if (el.closest('#music-player-overlay, #music-player-fab, .music-player-fab-popout')) return true;
464        return false;
465    }
466
467    function canUseHoverSound() {
468        try {
469            if (global.matchMedia && !global.matchMedia('(hover: hover) and (pointer: fine)').matches) {
470                return false;
471            }
472        } catch (e) { /* ignore */ }
473        return true;
474    }
475
476    function findHoverSoundTarget(el) {
477        if (!el || !el.closest) return null;
478        var control = el.closest(HOVER_SOUND_SELECTOR);
479        if (!control) return null;
480        if (control.disabled || control.getAttribute('aria-disabled') === 'true') return null;
481        if (control.matches('a[href]') && !control.getAttribute('href')) return null;
482        return control;
483    }
484
485    function shouldSkipHoverSoundControl(control) {
486        if (!control) return true;
487        if (control.closest('#site-theme-toggle, .site-theme-toggle, .settingsbtn')) return true;
488        if (control.closest('#hamburger-menu-btn, #sidebar-overlay, #sidebar-menu')) return true;
489        if (control.closest('label.settings-switch')) return true;
490        if (control.closest('#settings-panel button')) return true;
491        return false;
492    }
493
494    function bindHoverFeedback() {
495        document.addEventListener('pointerover', function (e) {
496            if (!isEnabled() || !canUseHoverSound()) return;
497            var target = e.target;
498            if (!target || shouldSkipSoundTarget(target)) return;
499
500            var control = findHoverSoundTarget(target);
501            if (!control || shouldSkipHoverSoundControl(control)) return;
502
503            var related = e.relatedTarget;
504            if (related && control.contains(related)) return;
505
506            var now = Date.now();
507            if (now - hoverSoundLastAt < HOVER_THRUM_MIN_GAP_MS) return;
508            hoverSoundLastAt = now;
509
510            playHoverThrum();
511        }, true);
512    }
513
514    function bindUiFeedback() {
515        document.addEventListener('change', function (e) {
516            if (!isEnabled()) return;
517            var t = e.target;
518            if (!t || shouldSkipSoundTarget(t)) return;
519
520            if (t.matches && t.matches('input.settings-switch__input[type="checkbox"]')) {
521                if (t.id === 'setting-darkMode') return;
522                if (t.id === 'setting-uiSounds') {
523                    if (!t.checked) playToggle(false);
524                    return;
525                }
526                playToggle(!!t.checked);
527                return;
528            }
529
530            if (t.matches && t.matches('#settings-panel select, .settings-select')) {
531                playSelectChange();
532                return;
533            }
534
535            if (t.matches && t.matches('input[name="follower"]')) {
536                playSoftClick();
537            }
538        }, true);
539
540        document.addEventListener('click', function (e) {
541            if (!isEnabled()) return;
542            var target = e.target;
543            if (!target || shouldSkipSoundTarget(target)) return;
544
545            if (target.closest('#settings-panel button')) {
546                var panelBtn = target.closest('#settings-panel button');
547                if (panelBtn.id === 'settings-close' || panelBtn.id === 'settings-reset') return;
548                playSoftClick();
549                return;
550            }
551
552            if (target.closest('#site-theme-toggle, .site-theme-toggle, .settingsbtn')) return;
553            if (target.closest('#hamburger-menu-btn, #sidebar-overlay, #sidebar-menu')) return;
554            if (target.closest('label.settings-switch')) return;
555
556            if (!document.body || !document.body.classList.contains('dashboard')) return;
557
558            var control = target.closest(
559                'button, input[type="submit"], input[type="button"], a.tool-button, .tool-button, .info-link, .bookings-contact-link'
560            );
561            if (!control || control.disabled || control.getAttribute('aria-disabled') === 'true') return;
562            if (control.closest('#settings-panel, #sidebar-menu, #hamburger-menu-btn')) return;
563            if (control.closest('#site-theme-toggle, .site-theme-toggle, .settingsbtn')) return;
564
565            playSoftClick();
566        }, false);
567    }
568
569    function playSettingsOpen() {
570        return playWhenReady(function () {
571            var audioCtx = ensureContext();
572            if (!audioCtx) return;
573            var start = audioCtx.currentTime;
574            tone(440, 0.06, 'sine', 0.09, start);
575            tone(660, 0.08, 'sine', 0.08, start + 0.045);
576        });
577    }
578
579    function playThemeToggle() {
580        return playWhenReady(function () {
581            var audioCtx = ensureContext();
582            if (!audioCtx) return;
583            var start = audioCtx.currentTime;
584            tone(520, 0.09, 'sine', 0.1, start);
585            tone(780, 0.11, 'sine', 0.085, start + 0.055);
586        });
587    }
588
589    function playToast() {
590        return playWhenReady(function () {
591            tone(880, 0.06, 'sine', 0.08);
592        });
593    }
594
595    function bindUnlock() {
596        var unlock = function () {
597            resumeContext().then(function (ok) {
598                if (ok) preloadWhoosh();
599            });
600        };
601        document.addEventListener('pointerdown', unlock, true);
602        document.addEventListener('keydown', unlock, true);
603    }
604
605    function bind() {
606        if (bound) return;
607        bound = true;
608        bindUnlock();
609        preloadWhoosh();
610
611        document.addEventListener('pupwife-site-theme', function (e) {
612            if (!e.detail || e.detail.source === 'boot' || e.detail.source === 'home-landing') return;
613            playThemeToggle();
614        });
615
616        document.addEventListener('pupwife:settings-opened', function () {
617            playSettingsOpen();
618        });
619
620        document.addEventListener('pupwife:settings-closed', function () {
621            playPanelClose();
622        });
623
624        document.addEventListener('pupwife:sidebar-opened', function () {
625            playMenuOpen();
626        });
627
628        document.addEventListener('pupwife:sidebar-closed', function (e) {
629            if (e.detail && e.detail.silent) return;
630            playMenuClose();
631        });
632
633        document.addEventListener('pupwife:setting-changed', function (e) {
634            if (!e.detail || e.detail.key !== 'uiSounds' || !e.detail.value) return;
635            playNavWhooshEnter();
636        });
637
638        bindUiFeedback();
639        bindHoverFeedback();
640    }
641
642    global.SiteAudio = {
643        playNavClick: playNavClick,
644        playNavWhoosh: playNavWhoosh,
645        playNavWhooshExit: playNavWhooshExit,
646        playNavWhooshEnter: playNavWhooshEnter,
647        scheduleNavWhoosh: scheduleNavWhoosh,
648        scheduleNavWhooshExit: scheduleNavWhooshExit,
649        scheduleNavWhooshEnter: scheduleNavWhooshEnter,
650        cancelNavWhoosh: cancelNavWhoosh,
651        playSettingsOpen: playSettingsOpen,
652        playThemeToggle: playThemeToggle,
653        playToast: playToast,
654        playToggle: playToggle,
655        playMenuOpen: playMenuOpen,
656        playMenuClose: playMenuClose,
657        playSoftClick: playSoftClick,
658        playHoverThrum: playHoverThrum,
659        playSelectChange: playSelectChange,
660        playPanelClose: playPanelClose,
661        isEnabled: isEnabled,
662        unlock: unlockAudio,
663        preloadWhoosh: preloadWhoosh,
664        ensureLoaded: bind
665    };
666
667    bind();
668})(typeof window !== 'undefined' ? window : this);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.