1/** 2 * @file audio.js 3 * @description Web Audio API sound engine and pausable timeout system. 4 * Provides synthesized notification sounds for each platform (WhatsApp, Instagram, 5 * TikTok, iMessage), a camera shutter effect, typing indicator audio, and background 6 * music control. Also implements a custom pausable timer system ({@link simTimeout}) 7 * that replaces native setTimeout so the entire simulation can be paused/resumed 8 * without losing timer state. 9 * @requires i18n.js - t() for UI label translation (pause/resume button text) 10 */ 11 12// ========== AUDIO ENGINE ========== 13 14/** @type {AudioContext} Shared Web Audio context used by all sound functions */ 15var ax; 16 17/** 18 * Initializes the shared AudioContext. Must be called from a user gesture (click) 19 * to satisfy browser autoplay policies. 20 */ 21function initAudio() { 22 ax = new (window.AudioContext || window.webkitAudioContext)(); 23 ax.resume(); 24} 25 26/** 27 * Core oscillator builder. Creates a single tone with an attack-decay envelope. 28 * Silently returns if the AudioContext is missing or the simulation is paused. 29 * @param {number} freq - Frequency in Hz 30 * @param {number} start - AudioContext time to begin playback 31 * @param {number} dur - Duration in seconds 32 * @param {number} [vol=0.1] - Peak gain (0-1) 33 * @param {string} [type='sine'] - OscillatorNode waveform type 34 */ 35function tone(freq, start, dur, vol, type) { 36 if (!ax || simPaused) return; 37 var o = ax.createOscillator(); 38 var g = ax.createGain(); 39 o.connect(g); 40 g.connect(ax.destination); 41 o.frequency.setValueAtTime(freq, start); 42 o.type = type || 'sine'; 43 // Quick 5ms attack ramp to avoid click artifacts, then exponential decay 44 g.gain.setValueAtTime(0, start); 45 g.gain.linearRampToValueAtTime(vol || 0.1, start + 0.005); 46 g.gain.exponentialRampToValueAtTime(0.001, start + dur); 47 o.start(start); 48 o.stop(start + dur); 49} 50 51/** 52 * WhatsApp-style notification: two-note rising chime (A5 -> D6). 53 */ 54function sndWa() { 55 if (!ax || simPaused) return; 56 var t = ax.currentTime; 57 tone(880, t, 0.12, 0.1); 58 tone(1175, t + 0.12, 0.15, 0.1); 59} 60 61/** 62 * Instagram-style notification: three-note ascending arpeggio (C6 -> E6 -> G6). 63 */ 64function sndIg() { 65 if (!ax || simPaused) return; 66 var t = ax.currentTime; 67 tone(1047, t, 0.06, 0.06); 68 tone(1319, t + 0.07, 0.06, 0.06); 69 tone(1568, t + 0.14, 0.08, 0.05); 70} 71 72/** 73 * TikTok-style notification: descending pitch sweep (1200 Hz -> 800 Hz) 74 * giving a short "bloop" effect. 75 */ 76function sndTk() { 77 if (!ax || simPaused) return; 78 var t = ax.currentTime; 79 var o = ax.createOscillator(); 80 var g = ax.createGain(); 81 o.connect(g); 82 g.connect(ax.destination); 83 // Rapid frequency sweep downward creates the characteristic bloop 84 o.frequency.setValueAtTime(1200, t); 85 o.frequency.exponentialRampToValueAtTime(800, t + 0.08); 86 o.type = 'sine'; 87 g.gain.setValueAtTime(0.08, t); 88 g.gain.exponentialRampToValueAtTime(0.001, t + 0.1); 89 o.start(t); 90 o.stop(t + 0.1); 91} 92 93/** 94 * iMessage-style notification: three-note ascending chime (C6 -> E6 -> G6), 95 * slightly longer and louder than the Instagram sound. 96 */ 97function sndIm() { 98 if (!ax || simPaused) return; 99 var t = ax.currentTime; 100 tone(1047, t, 0.1, 0.08); 101 tone(1319, t + 0.12, 0.1, 0.08); 102 tone(1568, t + 0.24, 0.15, 0.07); 103} 104 105/** 106 * Camera shutter sound: synthesized from a short burst of white noise 107 * shaped with an exponential decay envelope and high-pass filtered at 2 kHz 108 * to simulate the mechanical "click" of a camera. 109 */ 110function sndShutter() { 111 if (!ax || simPaused) return; 112 var t = ax.currentTime; 113 // Generate 80ms of white noise with exponential decay 114 var b = ax.createBuffer(1, ax.sampleRate * 0.08, ax.sampleRate); 115 var d = b.getChannelData(0); 116 for (var i = 0; i < d.length; i++) { 117 var env = Math.exp(-i / (d.length * 0.08)); 118 d[i] = (Math.random() * 2 - 1) * env * 0.4; 119 } 120 var s = ax.createBufferSource(); 121 var g = ax.createGain(); 122 var f = ax.createBiquadFilter(); 123 s.buffer = b; 124 // High-pass filter removes rumble, keeping only the sharp click 125 f.type = 'highpass'; 126 f.frequency.value = 2000; 127 s.connect(f); 128 f.connect(g); 129 g.connect(ax.destination); 130 g.gain.setValueAtTime(0.2, t); 131 s.start(t); 132} 133 134/** 135 * Phone vibration buzz: low-frequency sawtooth wave (150 Hz) that mimics 136 * the haptic buzz of a phone notification. 137 */ 138function sndBuzz() { 139 if (!ax || simPaused) return; 140 var t = ax.currentTime; 141 var o = ax.createOscillator(); 142 var g = ax.createGain(); 143 o.connect(g); 144 g.connect(ax.destination); 145 o.frequency.setValueAtTime(150, t); 146 o.type = 'sawtooth'; 147 g.gain.setValueAtTime(0.04, t); 148 g.gain.exponentialRampToValueAtTime(0.001, t + 0.15); 149 o.start(t); 150 o.stop(t + 0.15); 151} 152 153/** 154 * Starts a rapid stream of faint, randomized high-pitched ticks (2500-3300 Hz) 155 * to simulate the sound of someone typing on a phone keyboard. 156 */ 157var typActive = false;
158function typStart() { 159 if (!ax) return; 160 typActive = true; 161 function typTick() { 162 if (!typActive) return; 163 tone(2500 + Math.random() * 800, ax.currentTime, 0.015, 0.01, 'sine'); 164 simTimeout(typTick, 60 + Math.random() * 40); 165 } 166 simTimeout(typTick, 60); 167} 168 169/** 170 * Stops the typing indicator sound loop. 171 */ 172function typStop() { 173 typActive = false; 174} 175 176// ========== BACKGROUND MUSIC ========== 177 178/** @type {HTMLAudioElement|null} Reference to the <audio id="bgm"> element */ 179var bgMusic; 180 181/** 182 * Starts the background music track at 40% volume. 183 * Silently catches play() rejections (e.g. if autoplay is blocked). 184 */ 185function startMusic() { 186 bgMusic = document.getElementById('bgm'); 187 if (bgMusic) { 188 bgMusic.volume = 0.4; 189 bgMusic.play().catch(function () {}); 190 } 191} 192 193// ========== PAUSABLE TIMEOUT SYSTEM ========== 194// 195// Architecture: Every scene uses simTimeout() instead of native setTimeout(). 196// Each timer stores its remaining time. On pause, all native timeouts are cleared 197// and elapsed time is subtracted from `remaining`. On resume, each timer is 198// rescheduled with its remaining duration. This lets the entire simulation 199// freeze and resume seamlessly without drifting or losing callbacks. 200 201/** @type {boolean} Global pause flag checked by all sounds and intervals */ 202var simPaused = false; 203 204/** 205 * @type {number} Test-only time-lapse factor, set via URL parameter 206 * `?testspeed=N` (integer 1-60). All simTimeout delays and the progress-bar 207 * tick interval are divided by it, so N=10 runs the 120s simulation in ~12s. 208 * Defaults to 1 (real time); invalid or out-of-range values fall back to 1, 209 * so production behavior is unchanged unless the parameter is given explicitly. 210 * Used by the E2E tests (scripts/run-e2e.js), see docs/adr/ADR-0006. 211 */ 212var SIM_SPEED = (function () { 213 var m = /[?&]testspeed=(\d+)/.exec(window.location.search); 214 var v = m ? parseInt(m[1], 10) : 1; 215 return v >= 1 && v <= 60 ? v : 1; 216})(); 217 218/** 219 * @type {Array<{id: number, fn: Function, remaining: number, startedAt: number, nativeId: number, schedule: Function}>} 220 * Active pausable timers. Each entry tracks the callback, how many ms remain, 221 * when it was last (re-)started, and a schedule() function to arm it. 222 */ 223var simTimers = []; 224 225/** @type {number} Monotonically increasing counter for timer IDs */ 226var simTimerIdCounter = 0; 227 228/** 229 * Pausable replacement for setTimeout. Registers a callback that survives 230 * pause/resume cycles by tracking remaining time. 231 * 232 * Flow: schedule() arms a native setTimeout for `remaining` ms. When it fires, 233 * the timer removes itself from the list and calls fn(). If togglePause() is 234 * called before it fires, the native timeout is cleared, elapsed time is 235 * subtracted from `remaining`, and schedule() is called again on resume. 236 * 237 * @param {Function} fn - Callback to execute after delay 238 * @param {number} delay - Delay in milliseconds 239 * @returns {number} Unique timer ID (not the native setTimeout ID) 240 */ 241function simTimeout(fn, delay) { 242 var id = ++simTimerIdCounter; 243 var timer = { id: id, fn: fn, remaining: delay / SIM_SPEED, startedAt: Date.now() }; 244 245 function schedule() { 246 timer.startedAt = Date.now(); 247 timer.nativeId = setTimeout(function () { 248 // Timer has fired -- remove from active list, then execute callback 249 simTimers = simTimers.filter(function (t) { 250 return t.id !== id; 251 }); 252 fn(); 253 }, timer.remaining); 254 } 255 256 timer.schedule = schedule; 257 simTimers.push(timer); 258 schedule(); 259 return id; 260} 261 262/** 263 * Toggles the simulation between paused and running states. 264 * 265 * Pause coordination (all five systems are frozen/resumed together): 266 * 1. simTimers -- clear native timeouts, snapshot remaining time 267 * 2. bgMusic -- pause/resume the <audio> element 268 * 3. clockInt -- stop/restart the phone clock display interval 269 * 4. tmr -- stop/restart the progress bar tick interval 270 * 5. UI -- swap button label, show/hide pause overlay 271 * 272 * On resume, clockStart is recalculated so the phone clock picks up where 273 * it left off without jumping forward. 274 */ 275function togglePause() { 276 simPaused = !simPaused; 277 var btn = document.getElementById('pauseBtn');
278 var overlay = document.getElementById('pauseOverlay'); 279 280 if (simPaused) { 281 // -- PAUSE: freeze everything -- 282 simTimers.forEach(function (t) { 283 clearTimeout(t.nativeId); 284 // Calculate how much time has already elapsed and subtract it 285 t.remaining -= Date.now() - t.startedAt; 286 if (t.remaining < 0) t.remaining = 0; 287 }); 288 if (bgMusic) bgMusic.pause(); 289 if (typeof clockInt !== 'undefined') clearInterval(clockInt); 290 if (typeof tmr !== 'undefined') clearInterval(tmr); 291 btn.textContent = t('ui.resume'); 292 overlay.classList.remove('hidden'); 293 } else { 294 // -- RESUME: restart everything with corrected offsets -- 295 simTimers.forEach(function (t) { 296 t.schedule(); 297 }); 298 if (bgMusic) bgMusic.play().catch(function () {}); 299 // Recompute clockStart so elapsed time stays consistent after the pause gap 300 clockStart = Date.now() - sec * 1000; 301 if (typeof startClock === 'function') startClock(); 302 tmr = setInterval(tick, 100 / SIM_SPEED); 303 btn.textContent = t('ui.pause'); 304 overlay.classList.add('hidden'); 305 } 306}
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.