// Cozy WebAudio sound — warm and quiet, not techy. Sine/triangle voices run // through a soft lowpass + a little reverb; notes are pentatonic so nothing // ever clashes; envelopes fade in/out so there are no clicks. Low master gain. // No audio files. Mute with M. let ctx = null, bus = null, master = null, muted = false, dead = false; const MASTER = 0.32; // overall cosiness ceiling — everything sits under this // looping background music (mp3 in static/audio/). Routed past the SFX // lowpass/reverb so it stays full, but still under master + M mute. const MUSIC_SRC = "/static/audio/bgm.mp3"; const MUSIC_VOL = 0.15; // sits gently under the SFX — tune to taste let musicEl = null, musicGain = null, musicWired = false; function ac() { if (dead) return null; try { if (!ctx) { ctx = new (window.AudioContext || window.webkitAudioContext)(); master = ctx.createGain(); master.gain.value = MASTER; master.connect(ctx.destination); // warm everything: a gentle lowpass shaves the harsh top end const lp = ctx.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = 2600; lp.Q.value = 0.5; lp.connect(master); // a little room: short generated reverb, mixed in low const wet = ctx.createGain(); wet.gain.value = 0.16; const verb = ctx.createConvolver(); verb.buffer = impulse(0.9, 2.6); verb.connect(wet); wet.connect(master); bus = ctx.createGain(); bus.gain.value = 1; bus.connect(lp); // dry bus.connect(verb); // wet } if (ctx.state === "suspended") ctx.resume(); return ctx; } catch { dead = true; // no audio device — game keeps working, silently return null; } } function impulse(seconds, decay) { const a = ctx, len = Math.floor(a.sampleRate * seconds); const buf = a.createBuffer(2, len, a.sampleRate); for (let ch = 0; ch < 2; ch++) { const d = buf.getChannelData(ch); for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay); } return buf; } // pentatonic C-major — any mix of these sounds pleasant and warm const N = { C3: 130.81, E3: 164.81, G3: 196.0, C4: 261.63, D4: 293.66, E4: 329.63, G4: 392.0, A4: 440.0, C5: 523.25, D5: 587.33, E5: 659.25, G5: 783.99, A5: 880.0, C6: 1046.5, D6: 1174.7, E6: 1318.5, G6: 1568.0, }; // one soft voice with a click-free envelope function tone(freq, o = {}) { if (muted) return; const a = ac(), t0 = a.currentTime + (o.t || 0); const dur = o.dur || 0.2, atk = o.attack || 0.012, rel = o.rel || dur * 0.85; const osc = a.createOscillator(); osc.type = o.type || "sine"; osc.frequency.setValueAtTime(freq, t0); if (o.glideTo) osc.frequency.exponentialRampToValueAtTime(o.glideTo, t0 + dur); if (o.detune) osc.detune.value = o.detune; const g = a.createGain(); g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(o.gain || 0.1, t0 + atk); g.gain.exponentialRampToValueAtTime(0.0001, t0 + atk + rel); osc.connect(g).connect(bus); osc.start(t0); osc.stop(t0 + atk + rel + 0.05); // a faint octave/fifth layer warms plucks without making them louder if (o.warm) tone(freq * o.warm, { ...o, warm: 0, gain: (o.gain || 0.1) * 0.35 }); } // filtered noise — footsteps, paper, whooshes (cloth/wood, never beeps) function noise(o = {}) { if (muted) return; const a = ac(), t0 = a.currentTime + (o.t || 0), dur = o.dur || 0.08; const buf = a.createBuffer(1, Math.floor(a.sampleRate * dur), a.sampleRate); const d = buf.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; const src = a.createBufferSource(); src.buffer = buf; const f = a.createBiquadFilter(); f.type = o.filter || "lowpass"; f.frequency.setValueAtTime(o.cutoff || 700, t0); f.Q.value = o.q || 0.7; if (o.sweep) f.frequency.linearRampToValueAtTime(o.sweep, t0 + dur); const g = a.createGain(); g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(o.gain || 0.04, t0 + (o.attack || 0.005)); g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); src.connect(f).connect(g).connect(bus); src.start(t0); src.stop(t0 + dur + 0.02); } // play a little melody/arpeggio function seq(freqs, gap, o = {}) { freqs.forEach((f, i) => tone(f, { ...o, t: (o.t || 0) + i * gap })); } // ---- ambient office bed: a near-silent warm drone, always running ---- let bed = null; function ambientStart() { if (bed) return; const a = ac(); const g = a.createGain(); g.gain.value = 0.0001; g.connect(bus); g.gain.exponentialRampToValueAtTime(0.013, a.currentTime + 3); const f = a.createBiquadFilter(); f.type = "lowpass"; f.frequency.value = 380; f.Q.value = 0.4; f.connect(g); const o1 = a.createOscillator(); o1.type = "sine"; o1.frequency.value = N.C3; const o2 = a.createOscillator(); o2.type = "sine"; o2.frequency.value = N.C3 + 0.6; const o3 = a.createOscillator(); o3.type = "sine"; o3.frequency.value = N.G3; const g3 = a.createGain(); g3.gain.value = 0.4; o1.connect(f); o2.connect(f); o3.connect(g3).connect(f); // a very slow filter drift so it breathes const lfo = a.createOscillator(); lfo.frequency.value = 0.06; const lg = a.createGain(); lg.gain.value = 60; lfo.connect(lg).connect(f.frequency); [o1, o2, o3, lfo].forEach((x) => x.start()); bed = { g, nodes: [o1, o2, o3, lfo] }; } function ambientStop() { if (!bed) return; const a = ac(), { g, nodes } = bed; g.gain.exponentialRampToValueAtTime(0.0001, a.currentTime + 1.2); nodes.forEach((x) => { try { x.stop(a.currentTime + 1.4); } catch {} }); bed = null; } // ---- looping background music (mp3) ---- function musicStart() { try { if (!musicEl) { musicEl = new Audio(MUSIC_SRC); musicEl.loop = true; musicEl.preload = "auto"; musicEl.style.display = "none"; document.body.appendChild(musicEl); // attach for robustness + inspection } musicEl.muted = muted; const a = ac(); if (a && !musicWired) { try { const src = a.createMediaElementSource(musicEl); musicGain = a.createGain(); musicGain.gain.value = 0.0001; src.connect(musicGain).connect(master); // past the SFX lowpass/reverb musicGain.gain.exponentialRampToValueAtTime(MUSIC_VOL, a.currentTime + 2.5); musicWired = true; } catch { musicEl.volume = MUSIC_VOL; } } else if (!a) { musicEl.volume = MUSIC_VOL; // no WebAudio — plain element } else if (musicGain) { musicGain.gain.exponentialRampToValueAtTime(MUSIC_VOL, a.currentTime + 1.5); } const p = musicEl.play(); if (p && p.catch) p.catch(() => {}); // autoplay block — ignore } catch {} } function musicStop() { if (!musicEl) return; try { if (musicGain && ctx) { musicGain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 1.2); setTimeout(() => { try { musicEl.pause(); } catch {} }, 1300); } else { musicEl.pause(); } } catch {} } // ---- rate limiters so footsteps / typing never stack into a buzz ---- let lastFoot = 0, footToggle = 0, lastType = 0; export const sfx = { // movement & ambient footstep() { const now = performance.now(); if (now - lastFoot < 250) return; lastFoot = now; footToggle ^= 1; noise({ dur: 0.06, cutoff: footToggle ? 540 : 600, q: 1.1, gain: 0.022 }); tone(footToggle ? N.C3 : N.E3, { type: "sine", dur: 0.05, gain: 0.012 }); }, bump() { noise({ dur: 0.11, cutoff: 300, gain: 0.03 }); tone(90, { type: "sine", dur: 0.12, gain: 0.035, glideTo: 70 }); }, ambientStart, ambientStop, musicStart, musicStop, // dialogue & UI open() { seq([N.E5, N.A5], 0.06, { type: "sine", gain: 0.06, dur: 0.22 }); }, close() { seq([N.A5, N.E5], 0.06, { type: "sine", gain: 0.045, dur: 0.2 }); }, type() { const now = performance.now(); if (now - lastType < 55) return; lastType = now; tone(350 + Math.random() * 36, { type: "triangle", dur: 0.035, gain: 0.016, attack: 0.002 }); }, click() { tone(N.A5, { type: "triangle", dur: 0.1, gain: 0.05, warm: 0.5 }); }, send() { seq([N.G5, N.C6], 0.05, { type: "sine", gain: 0.06, dur: 0.16 }); }, prompt() { tone(N.D6, { type: "sine", dur: 0.12, gain: 0.028 }); }, comic() { noise({ dur: 0.34, cutoff: 420, sweep: 1800, gain: 0.04 }); // page reveal seq([N.G5, N.C6, N.E6], 0.07, { type: "sine", gain: 0.04, dur: 0.26, warm: 0.5 }); }, // event arrivals bubble() { tone(N.E5, { type: "sine", dur: 0.2, gain: 0.06, glideTo: N.A5 }); }, amberBubble() { tone(N.D5, { type: "sine", dur: 0.22, gain: 0.05, glideTo: N.G4 }); }, heart() { seq([N.E5, N.A5, N.C6], 0.07, { type: "sine", gain: 0.045, dur: 0.24 }); tone(N.E6, { t: 0.2, dur: 0.32, gain: 0.022 }); }, newspaper() { noise({ dur: 0.4, cutoff: 380, sweep: 1700, gain: 0.04 }); noise({ t: 0.32, dur: 0.05, cutoff: 2400, gain: 0.03 }); noise({ t: 0.4, dur: 0.05, cutoff: 2000, gain: 0.025 }); }, envelope() { noise({ dur: 0.3, cutoff: 1100, sweep: 520, gain: 0.035, filter: "bandpass", q: 0.8 }); }, mail() { seq([N.G5, N.C6], 0.13, { type: "sine", gain: 0.05, dur: 0.32, warm: 0.5 }); }, phoneRingStart() { if (sfx._ring) return; const ring = () => { seq([N.E5, N.G5], 0.12, { type: "sine", gain: 0.04, dur: 0.22 }); seq([N.E5, N.G5], 0.12, { type: "sine", gain: 0.04, dur: 0.22, t: 0.3 }); }; ring(); sfx._ring = setInterval(ring, 1400); }, phoneRingStop() { if (sfx._ring) { clearInterval(sfx._ring); sfx._ring = null; } }, // outcomes moneyUp() { seq([N.C5, N.E5, N.G5, N.C6], 0.06, { type: "triangle", gain: 0.055, dur: 0.24, warm: 0.5 }); }, moneyDown() { seq([N.A5, N.G5, N.E5, N.C5], 0.07, { type: "sine", gain: 0.05, dur: 0.26 }); }, disaster() { noise({ dur: 0.28, cutoff: 220, gain: 0.05 }); tone(72, { type: "sine", dur: 0.4, gain: 0.05, glideTo: 54 }); }, titleSwap() { seq([N.D6, N.G6], 0.04, { type: "sine", gain: 0.02, dur: 0.12 }); }, trail() { noise({ dur: 0.04, cutoff: 3200, gain: 0.013, filter: "highpass" }); }, gift() { seq([N.G5, N.A5, N.C6], 0.05, { type: "triangle", gain: 0.045, dur: 0.2 }); }, coffee() { [N.C5, N.E5, N.G5].forEach((f) => tone(f, { type: "sine", gain: 0.038, dur: 0.5 })); }, // boardroom / presentation gold() { seq([N.G5, N.D6, N.G6], 0.08, { type: "sine", gain: 0.045, dur: 0.5 }); }, wipe() { noise({ dur: 0.35, cutoff: 550, sweep: 1900, gain: 0.035 }); }, boardIn() { noise({ dur: 0.08, cutoff: 380, gain: 0.03 }); noise({ t: 0.16, dur: 0.08, cutoff: 420, gain: 0.03 }); }, wrongSlide() { tone(N.G5, { type: "triangle", dur: 0.42, gain: 0.06, glideTo: N.D5 }); tone(N.E5, { t: 0.4, type: "triangle", dur: 0.32, gain: 0.05, glideTo: N.C5 }); }, score() { seq([N.C5, N.E5, N.G5], 0.08, { type: "triangle", gain: 0.05, dur: 0.2 }); tone(N.C6, { t: 0.28, dur: 0.5, gain: 0.05, warm: 0.5 }); }, stamp() { noise({ dur: 0.18, cutoff: 280, gain: 0.05 }); tone(110, { type: "sine", dur: 0.26, gain: 0.05, glideTo: 88 }); }, // endings win() { seq([N.C5, N.E5, N.G5, N.C6, N.E6, N.G6], 0.09, { type: "triangle", gain: 0.055, dur: 0.42, warm: 0.5 }); [N.C5, N.E5, N.G5].forEach((f) => tone(f, { t: 0.6, dur: 0.9, gain: 0.04 })); }, lose(tier) { const sets = { hit_target: [N.C5, N.E5, N.G5], above_600k: [N.A4, N.G4, N.E4], "300k_to_600k": [N.G4, N.E4, N.C4], below_300k: [N.E4, N.D4, N.C4, N.C3], }; seq(sets[tier] || sets["300k_to_600k"], 0.22, { type: "sine", gain: 0.05, dur: 0.4 }); }, confetti() { for (let i = 0; i < 12; i++) { const notes = [N.C6, N.D6, N.E6, N.G6, N.A5]; tone(notes[(Math.random() * notes.length) | 0], { t: Math.random() * 1.1, type: "sine", dur: 0.18, gain: 0.03 }); } }, review() { [N.C4, N.G4, N.C5].forEach((f, i) => tone(f, { type: "sine", dur: 1.1, gain: 0.035, t: i * 0.08 })); }, play() { seq([N.G5, N.C6], 0.07, { type: "sine", gain: 0.05, dur: 0.25 }); }, toggleMute() { muted = !muted; if (master) master.gain.exponentialRampToValueAtTime( muted ? 0.0001 : MASTER, (ctx ? ctx.currentTime : 0) + 0.15); if (musicEl) musicEl.muted = muted; // covers both routed + plain-element paths return muted; }, };