feat(lowball-blitz): add Strudel BGM + Web Audio SFX with mute toggle

Integrate procedural audio system using @strudel/web for looping BGM
and Web Audio API for one-shot SFX. Gameplay BGM is an energetic
mischievous chiptune at 130 cpm with anti-repetition techniques
(cycle alternation, layer phasing, probabilistic notes). Game over
theme is somber at 60 cpm. Six SFX mapped to game events: throw,
hit, combo, damage, collect, near-miss. Mute button (bottom-left)
with M keyboard shortcut, preference persisted to localStorage.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
rshtirmer
2026-02-26 16:26:53 -05:00
parent 354107749d
commit 43e0cfe4dd
8 changed files with 436 additions and 2 deletions
+29
View File
@@ -160,6 +160,32 @@
pointer-events: none;
}
/* Mute button */
#mute-btn {
position: fixed;
bottom: max(20px, 3vh);
left: 16px;
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.35);
border: 2px solid rgba(255, 255, 255, 0.25);
color: #fff;
font-size: 20px;
display: flex;
justify-content: center;
align-items: center;
z-index: 16;
cursor: pointer;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
transition: background 0.2s;
line-height: 1;
}
#mute-btn:hover { background: rgba(0, 0, 0, 0.55); }
#mute-btn:active { background: rgba(0, 0, 0, 0.7); }
/* Mobile hint text */
#mobile-hints {
position: fixed;
@@ -203,6 +229,9 @@
<!-- Mobile hints -->
<div id="mobile-hints">Tap left/right to dodge | Tap right side to throw</div>
<!-- Mute button -->
<button id="mute-btn" aria-label="Mute audio">&#x1F50A;</button>
<script type="module" src="/src/main.js"></script>
</body>
</html>
@@ -0,0 +1,53 @@
import { eventBus, Events } from '../core/EventBus.js';
import { gameState } from '../core/GameState.js';
import { audioManager } from './AudioManager.js';
import { gameplayBGM, gameOverTheme } from './music.js';
import { throwSfx, hitSfx, comboSfx, damageSfx, collectSfx, nearMissSfx } from './sfx.js';
export function initAudioBridge() {
// Init Strudel on first user interaction (browser autoplay policy)
eventBus.on(Events.AUDIO_INIT, () => audioManager.init());
// --- BGM transitions (Strudel) ---
eventBus.on(Events.GAME_START, () => audioManager.playMusic(gameplayBGM));
eventBus.on(Events.GAME_RESTART, () => audioManager.playMusic(gameplayBGM));
eventBus.on(Events.GAME_OVER, () => audioManager.playMusic(gameOverTheme));
eventBus.on(Events.MUSIC_GAMEPLAY, () => audioManager.playMusic(gameplayBGM));
eventBus.on(Events.MUSIC_GAMEOVER, () => audioManager.playMusic(gameOverTheme));
eventBus.on(Events.MUSIC_STOP, () => audioManager.stopMusic());
// --- SFX (Web Audio API -- direct one-shot calls) ---
eventBus.on(Events.ENVELOPE_THROWN, () => throwSfx());
eventBus.on(Events.HOUSE_HIT, () => hitSfx());
eventBus.on(Events.COMBO_CHANGED, (data) => {
if (data && data.combo >= 3) {
comboSfx(data.combo);
}
});
eventBus.on(Events.PLAYER_HIT, () => damageSfx());
eventBus.on(Events.PANIC_COLLECTED, () => collectSfx());
eventBus.on(Events.SPECTACLE_NEAR_MISS, () => nearMissSfx());
// --- Mute toggle ---
eventBus.on(Events.AUDIO_TOGGLE_MUTE, () => {
gameState.isMuted = !gameState.isMuted;
try { localStorage.setItem('lowball-blitz-muted', gameState.isMuted); } catch (_) { /* noop */ }
if (gameState.isMuted) {
audioManager.stopMusic();
} else if (gameState.started && !gameState.gameOver) {
// Resume gameplay music when unmuting during active gameplay
audioManager.playMusic(gameplayBGM);
}
// Update mute button icon
_updateMuteButton();
});
}
/** Update the mute button UI (called from AudioBridge to keep audio concerns together) */
function _updateMuteButton() {
const btn = document.getElementById('mute-btn');
if (btn) {
btn.textContent = gameState.isMuted ? '\u{1F507}' : '\u{1F50A}';
btn.setAttribute('aria-label', gameState.isMuted ? 'Unmute audio' : 'Mute audio');
}
}
@@ -0,0 +1,41 @@
import { initStrudel, hush } from '@strudel/web';
import { gameState } from '../core/GameState.js';
class AudioManager {
constructor() {
this.initialized = false;
this.currentMusic = null;
}
init() {
if (this.initialized) return;
try {
initStrudel();
this.initialized = true;
console.log('[Audio] Strudel initialized');
} catch (e) {
console.warn('[Audio] Strudel init failed:', e);
}
}
playMusic(patternFn) {
if (!this.initialized || gameState.isMuted) return;
this.stopMusic();
// hush() needs a scheduler tick to process before new pattern starts
setTimeout(() => {
try {
this.currentMusic = patternFn();
} catch (e) {
console.warn('[Audio] BGM error:', e);
}
}, 100);
}
stopMusic() {
if (!this.initialized) return;
try { hush(); } catch (e) { /* noop */ }
this.currentMusic = null;
}
}
export const audioManager = new AudioManager();
+96
View File
@@ -0,0 +1,96 @@
import { stack, note } from '@strudel/web';
/**
* Gameplay BGM (~130 cpm) -- energetic, mischievous suburban runner vibe.
* Layers: bass + lead melody + counter melody + synth kick + arp texture.
* Uses cycle alternation, layer phasing, probabilistic notes, and filter cycling
* for 30+ second effective loop before exact repetition.
*/
export function gameplayBGM() {
return stack(
// Lead melody -- 4 alternating mischievous phrases (square for chiptune energy)
note('<[e4 g4 a4 ~ e4 g4 b4 ~ a4 g4 e4 ~ d4 e4 ~ ~] [g4 a4 b4 ~ g4 a4 c5 ~ b4 a4 g4 ~ e4 g4 ~ ~] [a4 ~ g4 e4 g4 ~ e4 d4 e4 ~ g4 a4 ~ ~ b4 ~] [b4 a4 g4 ~ e4 g4 a4 ~ g4 e4 d4 ~ e4 ~ ~ ~]>')
.s('square')
.gain(0.15)
.lpf(2500)
.decay(0.12)
.sustain(0.2)
.release(0.3),
// Counter melody -- sparse, offset phasing (1.5x cycle)
note('<[~ ~ b4 ~ ~ ~ e5? ~ ~ ~ ~ ~ g4? ~ ~ ~] [~ ~ ~ ~ ~ e5 ~ ~ ~ b4? ~ ~ ~ ~ g4 ~]>')
.s('square')
.gain(0.07)
.lpf('<3000 2200 2800 2000>')
.decay(0.15)
.sustain(0)
.slow(1.5),
// Bass -- 3 alternating root progressions (triangle for warmth)
note('<[e2 ~ e2 ~ a2 ~ a2 ~ d2 ~ d2 ~ g2 ~ g2 ~] [a2 ~ a2 ~ d2 ~ d2 ~ g2 ~ g2 ~ c2 ~ c2 ~] [e2 ~ g2 ~ a2 ~ e2 ~ d2 ~ g2 ~ c2 ~ e2 ~]>')
.s('triangle')
.gain(0.2)
.lpf(500),
// Synth kick drum -- 2 alternating patterns (sine for thump)
note('<[c1 ~ c1 ~ c1 c1 ~ ~ c1 ~ c1 ~ c1 ~ c1 ~] [c1 c1 ~ ~ c1 ~ c1 ~ ~ c1 ~ c1 c1 ~ ~ c1]>')
.s('sine')
.gain(0.25)
.decay(0.12)
.sustain(0)
.lpf(200),
// Hi-hat texture -- probabilistic for organic feel
note('c6 c6? c6 c6? c6 c6? c6 c6?')
.s('square')
.gain(0.04)
.decay(0.03)
.sustain(0)
.lpf('<6000 5000 7000 5500>')
.fast(2),
// Arp texture -- filter cycling, phased against other layers
note('e3 g3 b3 e4')
.s('square')
.fast(4)
.gain(0.04)
.lpf('<1000 700 1400 900>')
.decay(0.06)
.sustain(0)
.slow(3)
).cpm(130).play();
}
/**
* Game over theme (~60 cpm) -- somber, descending, short looping phrase.
* 3 alternating descending melodies + dark pad + ghostly texture.
*/
export function gameOverTheme() {
return stack(
// Descending melody -- 3 variations (triangle for softness)
note('<[b4 ~ a4 ~ g4 ~ e4 ~ d4 ~ c4 ~ ~ ~ ~ ~] [e4 ~ d4 ~ c4 ~ b3 ~ a3 ~ g3 ~ ~ ~ ~ ~] [g4 ~ e4 ~ d4 ~ c4 ~ e4 ~ d4 ~ b3 ~ ~ ~]>')
.s('triangle')
.gain(0.16)
.decay(0.6)
.sustain(0.1)
.release(1.0)
.room(0.6)
.roomsize(5)
.lpf(1800),
// Dark pad -- alternating minor chords on slow cycle
note('<[a2,c3,e3] [d2,f2,a2] [e2,g2,b2]>')
.s('sine')
.attack(0.5)
.release(2.5)
.gain(0.1)
.room(0.7)
.roomsize(6)
.lpf(1200)
.slow(2),
// Ghostly high texture -- probabilistic, phased
note('~ ~ ~ ~ ~ e5? ~ ~ ~ ~ ~ ~ ~ b4? ~ ~')
.s('sine')
.gain(0.03)
.delay(0.5)
.delaytime(0.6)
.delayfeedback(0.5)
.room(0.7)
.lpf(2000)
.slow(3)
).slow(3).cpm(60).play();
}
+165
View File
@@ -0,0 +1,165 @@
/**
* SFX Engine -- Web Audio API one-shot sounds.
* NEVER use Strudel for SFX (it loops). All sounds here fire once and stop.
*/
import { gameState } from '../core/GameState.js';
let audioCtx = null;
function getCtx() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
return audioCtx;
}
/** Play a single tone that stops after duration */
function playTone(freq, type, duration, gain = 0.3, filterFreq = 4000) {
const ctx = getCtx();
const now = ctx.currentTime;
const osc = ctx.createOscillator();
osc.type = type;
osc.frequency.setValueAtTime(freq, now);
const gainNode = ctx.createGain();
gainNode.gain.setValueAtTime(gain, now);
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration);
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(filterFreq, now);
osc.connect(filter).connect(gainNode).connect(ctx.destination);
osc.start(now);
osc.stop(now + duration);
}
/** Play a sequence of tones (each fires once and stops) */
function playNotes(notes, type, noteDuration, gap, gain = 0.3, filterFreq = 4000) {
const ctx = getCtx();
const now = ctx.currentTime;
notes.forEach((freq, i) => {
const start = now + i * gap;
const osc = ctx.createOscillator();
osc.type = type;
osc.frequency.setValueAtTime(freq, start);
const gainNode = ctx.createGain();
gainNode.gain.setValueAtTime(gain, start);
gainNode.gain.exponentialRampToValueAtTime(0.001, start + noteDuration);
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(filterFreq, start);
osc.connect(filter).connect(gainNode).connect(ctx.destination);
osc.start(start);
osc.stop(start + noteDuration);
});
}
/** Play noise burst (for whooshes, swooshes) */
function playNoise(duration, gain = 0.2, lpfFreq = 4000, hpfFreq = 0) {
const ctx = getCtx();
const now = ctx.currentTime;
const bufferSize = Math.floor(ctx.sampleRate * duration);
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const source = ctx.createBufferSource();
source.buffer = buffer;
const gainNode = ctx.createGain();
gainNode.gain.setValueAtTime(gain, now);
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration);
const lpf = ctx.createBiquadFilter();
lpf.type = 'lowpass';
lpf.frequency.setValueAtTime(lpfFreq, now);
let chain = source.connect(lpf).connect(gainNode);
if (hpfFreq > 0) {
const hpf = ctx.createBiquadFilter();
hpf.type = 'highpass';
hpf.frequency.setValueAtTime(hpfFreq, now);
source.disconnect();
chain = source.connect(hpf).connect(lpf).connect(gainNode);
}
chain.connect(ctx.destination);
source.start(now);
source.stop(now + duration);
}
// Note frequencies for reference:
// C4=261.63 D4=293.66 E4=329.63 F4=349.23 G4=392.00
// A4=440.00 B4=493.88 C5=523.25 E5=659.25 B5=987.77
/**
* Throw SFX -- quick whoosh (short noise burst with pitch sweep).
* Feels like an envelope sailing through the air.
*/
export function throwSfx() {
if (gameState.isMuted) return;
playNoise(0.15, 0.18, 5000, 1000);
}
/**
* Hit SFX -- satisfying impact (low thump + high sparkle).
* Envelope smacking into a house.
*/
export function hitSfx() {
if (gameState.isMuted) return;
// Low thump
playTone(65.41, 'sine', 0.15, 0.28, 800);
// High sparkle
playTone(987.77, 'square', 0.1, 0.12, 6000);
}
/**
* Combo SFX -- ascending arpeggio.
* Scales with combo count (higher combos = more notes).
*/
export function comboSfx(comboCount) {
if (gameState.isMuted) return;
const baseNotes = [329.63, 440.00, 523.25, 659.25, 987.77];
// Use more notes for higher combos (3-5 notes)
const count = Math.min(Math.max(3, comboCount), baseNotes.length);
const notes = baseNotes.slice(0, count);
playNotes(notes, 'square', 0.08, 0.05, 0.22, 5000);
}
/**
* Damage SFX -- harsh buzz/dissonance.
* Getting hit by a real estate agent.
*/
export function damageSfx() {
if (gameState.isMuted) return;
// Descending harsh tones
playNotes([392, 329.63, 261.63, 220, 174.61], 'sawtooth', 0.12, 0.07, 0.25, 1500);
}
/**
* Collect SFX -- bright pickup chime (ascending two-note).
* Collecting panic points.
*/
export function collectSfx() {
if (gameState.isMuted) return;
playNotes([659.25, 987.77], 'square', 0.12, 0.07, 0.25, 5000);
}
/**
* Near miss SFX -- quick swoosh.
* Dodging an agent at the last moment.
*/
export function nearMissSfx() {
if (gameState.isMuted) return;
playNoise(0.12, 0.12, 3000, 600);
}
@@ -47,6 +47,7 @@ export const Events = {
// Audio (used by /add-audio)
AUDIO_INIT: 'audio:init',
AUDIO_TOGGLE_MUTE: 'audio:toggle_mute',
MUSIC_MENU: 'music:menu',
MUSIC_GAMEPLAY: 'music:gameplay',
MUSIC_GAMEOVER: 'music:gameover',
+4 -2
View File
@@ -25,8 +25,10 @@ class GameState {
this.housesHit = 0;
this.totalThrown = 0;
// Audio
this.isMuted = false;
// Audio -- persist mute preference across sessions
if (this.isMuted === undefined) {
try { this.isMuted = localStorage.getItem('lowball-blitz-muted') === 'true'; } catch (_) { this.isMuted = false; }
}
// Combo timer
this._comboTimer = 0;
+47
View File
@@ -2,9 +2,56 @@ import { Game } from './core/Game.js';
import { eventBus, Events } from './core/EventBus.js';
import { gameState } from './core/GameState.js';
import { IS_MOBILE } from './core/Constants.js';
import { initAudioBridge } from './audio/AudioBridge.js';
const game = new Game();
// --- Audio ---
initAudioBridge();
// Init audio on first user interaction (browser autoplay policy)
let audioInitDone = false;
function initAudioOnce() {
if (audioInitDone) return;
audioInitDone = true;
eventBus.emit(Events.AUDIO_INIT);
// Start gameplay music after init (game auto-starts, no title screen)
if (gameState.started && !gameState.gameOver) {
eventBus.emit(Events.MUSIC_GAMEPLAY);
}
}
window.addEventListener('click', initAudioOnce, { once: false });
window.addEventListener('touchstart', initAudioOnce, { once: false });
window.addEventListener('keydown', initAudioOnce, { once: false });
// M key toggles mute
window.addEventListener('keydown', (e) => {
if (e.key === 'm' || e.key === 'M') {
eventBus.emit(Events.AUDIO_TOGGLE_MUTE);
}
});
// Mute button click handler
const muteBtn = document.getElementById('mute-btn');
if (muteBtn) {
muteBtn.addEventListener('click', (e) => {
e.stopPropagation();
eventBus.emit(Events.AUDIO_TOGGLE_MUTE);
});
// Set initial icon based on persisted mute state
if (gameState.isMuted) {
muteBtn.textContent = '\u{1F507}';
muteBtn.setAttribute('aria-label', 'Unmute audio');
}
}
// On mobile, shift mute button up above joystick zone
if (IS_MOBILE) {
if (muteBtn) {
muteBtn.style.bottom = 'max(140px, calc(3vh + 120px))';
}
}
// Expose for Playwright testing
window.__GAME__ = game;
window.__GAME_STATE__ = gameState;