Created
July 11, 2026 04:59
-
-
Save toricls/b8b890e82aa5a836e6359e06ed311261 to your computer and use it in GitHub Desktop.
Flappy Kiro's SFX
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Audio Manager - handles programmatic sound generation using Web Audio API | |
| // Factory function for creating testable audio manager instances | |
| /** | |
| * Creates a new audio manager instance | |
| * @param {Object} options - Configuration options | |
| * @param {AudioContext} options.audioContext - Optional AudioContext for testing | |
| * @returns {Object} Audio manager with init, playJump, playPass, playCollision, playHighScore methods | |
| */ | |
| export function createAudioManager(options = {}) { | |
| // Load cached SFX preference from localStorage | |
| let cachedSfxEnabled = true; | |
| try { | |
| const stored = localStorage.getItem('flappyKiro_sfxEnabled'); | |
| if (stored !== null) { | |
| cachedSfxEnabled = stored === 'true'; | |
| } | |
| } catch (e) { | |
| // localStorage unavailable, use default | |
| } | |
| return { | |
| audioContext: options.audioContext || null, | |
| enabled: true, | |
| sfxEnabled: cachedSfxEnabled, // Sound effects toggle state (cached) | |
| /** | |
| * Initialize the AudioContext | |
| * Handles Web Audio API unavailability gracefully | |
| */ | |
| init() { | |
| try { | |
| // Use provided context (for testing) or create new one | |
| if (!this.audioContext) { | |
| const AudioContextClass = window.AudioContext || window.webkitAudioContext; | |
| if (!AudioContextClass) { | |
| this.enabled = false; | |
| console.warn('Web Audio API not available, audio disabled'); | |
| return; | |
| } | |
| this.audioContext = new AudioContextClass(); | |
| } | |
| this.enabled = true; | |
| // Resume context on user interaction if suspended | |
| if (this.audioContext.state === 'suspended') { | |
| const resumeAudio = () => { | |
| if (this.audioContext && this.audioContext.state === 'suspended') { | |
| this.audioContext.resume(); | |
| } | |
| }; | |
| document.addEventListener('click', resumeAudio, { once: true }); | |
| document.addEventListener('keydown', resumeAudio, { once: true }); | |
| } | |
| // Set up tab visibility listener to resume AudioContext when tab becomes active | |
| this.setupVisibilityListener(); | |
| } catch (e) { | |
| this.enabled = false; | |
| console.warn('Failed to initialize AudioContext:', e.message); | |
| } | |
| }, | |
| /** | |
| * Set up document visibility change listener | |
| * Resumes AudioContext when tab becomes visible again | |
| */ | |
| setupVisibilityListener() { | |
| document.addEventListener('visibilitychange', () => { | |
| this.handleVisibilityChange(); | |
| }); | |
| }, | |
| /** | |
| * Handle tab visibility changes | |
| * Resumes AudioContext when tab becomes visible (browser suspends it when hidden) | |
| */ | |
| handleVisibilityChange() { | |
| if (!document.hidden && this.audioContext && this.audioContext.state === 'suspended') { | |
| // Tab is now visible - resume AudioContext if suspended | |
| this.audioContext.resume().catch(e => { | |
| console.warn('Failed to resume AudioContext on visibility:', e.message); | |
| }); | |
| } | |
| }, | |
| /** | |
| * Helper to create oscillator-based tones | |
| * @param {number} frequency - Frequency in Hz | |
| * @param {number} duration - Duration in seconds | |
| * @param {string} type - Waveform type ('sine', 'triangle', 'square', 'sawtooth') | |
| * @param {number} volume - Volume level (0.0 - 1.0) | |
| * @returns {Object|null} Object with oscillator and gainNode, or null if audio unavailable | |
| */ | |
| createTone(frequency, duration, type = 'sine', volume = 0.3) { | |
| if (!this.enabled || !this.audioContext) { | |
| return null; | |
| } | |
| try { | |
| const oscillator = this.audioContext.createOscillator(); | |
| const gainNode = this.audioContext.createGain(); | |
| oscillator.type = type; | |
| oscillator.frequency.setValueAtTime(frequency, this.audioContext.currentTime); | |
| // Connect oscillator -> gain -> destination | |
| oscillator.connect(gainNode); | |
| gainNode.connect(this.audioContext.destination); | |
| // Set initial volume | |
| gainNode.gain.setValueAtTime(volume, this.audioContext.currentTime); | |
| return { oscillator, gainNode, frequency, duration, type, volume }; | |
| } catch (e) { | |
| console.warn('Failed to create tone:', e.message); | |
| return null; | |
| } | |
| }, | |
| /** | |
| * Play jump/flap sound | |
| * 400-600 Hz sine wave, 100-150ms duration | |
| * Quick attack and decay envelope | |
| */ | |
| playJump() { | |
| if (!this.enabled || !this.audioContext || !this.sfxEnabled) return; | |
| try { | |
| const frequency = 400 + Math.random() * 200; // 400-600 Hz | |
| const duration = 0.1 + Math.random() * 0.05; // 100-150ms | |
| const tone = this.createTone(frequency, duration, 'sine', 0.3); | |
| if (!tone) return; | |
| const { oscillator, gainNode } = tone; | |
| const now = this.audioContext.currentTime; | |
| // Quick attack and decay envelope | |
| gainNode.gain.setValueAtTime(0, now); | |
| gainNode.gain.linearRampToValueAtTime(0.3, now + 0.01); // Quick attack | |
| gainNode.gain.linearRampToValueAtTime(0, now + duration); // Decay | |
| oscillator.start(now); | |
| oscillator.stop(now + duration); | |
| } catch (e) { | |
| // Graceful degradation - continue silently | |
| } | |
| }, | |
| /** | |
| * Play obstacle pass chime | |
| * 800-1000 Hz sine wave, ~150ms duration | |
| */ | |
| playPass() { | |
| if (!this.enabled || !this.audioContext || !this.sfxEnabled) return; | |
| try { | |
| const frequency = 800 + Math.random() * 200; // 800-1000 Hz | |
| const duration = 0.15; | |
| const tone = this.createTone(frequency, duration, 'sine', 0.2); | |
| if (!tone) return; | |
| const { oscillator, gainNode } = tone; | |
| const now = this.audioContext.currentTime; | |
| // Pleasant envelope | |
| gainNode.gain.setValueAtTime(0, now); | |
| gainNode.gain.linearRampToValueAtTime(0.2, now + 0.02); | |
| gainNode.gain.linearRampToValueAtTime(0, now + duration); | |
| oscillator.start(now); | |
| oscillator.stop(now + duration); | |
| } catch (e) { | |
| // Graceful degradation - continue silently | |
| } | |
| }, | |
| /** | |
| * Play collision thud sound | |
| * 150-200 Hz triangle wave, ~200ms duration | |
| */ | |
| playCollision() { | |
| if (!this.enabled || !this.audioContext || !this.sfxEnabled) return; | |
| try { | |
| const frequency = 150 + Math.random() * 50; // 150-200 Hz | |
| const duration = 0.2; | |
| const tone = this.createTone(frequency, duration, 'triangle', 0.4); | |
| if (!tone) return; | |
| const { oscillator, gainNode } = tone; | |
| const now = this.audioContext.currentTime; | |
| // Impact envelope - quick attack, slower decay | |
| gainNode.gain.setValueAtTime(0, now); | |
| gainNode.gain.linearRampToValueAtTime(0.4, now + 0.01); | |
| gainNode.gain.linearRampToValueAtTime(0, now + duration); | |
| oscillator.start(now); | |
| oscillator.stop(now + duration); | |
| } catch (e) { | |
| // Graceful degradation - continue silently | |
| } | |
| }, | |
| /** | |
| * Play high score celebration fanfare | |
| * Ascending tone sequence: C5 (523Hz), E5 (659Hz), G5 (784Hz), C6 (1047Hz) | |
| * Each note ~100ms with slight overlap | |
| */ | |
| playHighScore() { | |
| if (!this.enabled || !this.audioContext || !this.sfxEnabled) return; | |
| try { | |
| const notes = [523, 659, 784, 1047]; // C5, E5, G5, C6 | |
| const noteDuration = 0.1; // 100ms each | |
| const now = this.audioContext.currentTime; | |
| notes.forEach((freq, index) => { | |
| const startTime = now + index * 0.08; // Slight overlap | |
| const oscillator = this.audioContext.createOscillator(); | |
| const gainNode = this.audioContext.createGain(); | |
| oscillator.type = 'sine'; | |
| oscillator.frequency.setValueAtTime(freq, startTime); | |
| oscillator.connect(gainNode); | |
| gainNode.connect(this.audioContext.destination); | |
| // Envelope for each note | |
| gainNode.gain.setValueAtTime(0, startTime); | |
| gainNode.gain.linearRampToValueAtTime(0.3, startTime + 0.01); | |
| gainNode.gain.linearRampToValueAtTime(0, startTime + noteDuration); | |
| oscillator.start(startTime); | |
| oscillator.stop(startTime + noteDuration); | |
| }); | |
| } catch (e) { | |
| // Graceful degradation - continue silently | |
| } | |
| }, | |
| /** | |
| * Get sound frequency configurations for testing | |
| * @returns {Object} Sound frequency ranges | |
| */ | |
| getSoundFrequencies() { | |
| return { | |
| jump: { min: 400, max: 600 }, | |
| pass: { min: 800, max: 1000 }, | |
| collision: { min: 150, max: 200 }, | |
| highScore: [523, 659, 784, 1047] | |
| }; | |
| }, | |
| /** | |
| * Toggle all sound effects on/off | |
| * Flips the sfxEnabled state and caches to localStorage | |
| */ | |
| toggleSFX() { | |
| this.sfxEnabled = !this.sfxEnabled; | |
| try { | |
| localStorage.setItem('flappyKiro_sfxEnabled', this.sfxEnabled.toString()); | |
| } catch (e) { | |
| // localStorage unavailable, continue without caching | |
| } | |
| }, | |
| /** | |
| * Get current SFX enabled state | |
| * @returns {boolean} Whether sound effects are enabled | |
| */ | |
| isSFXEnabled() { | |
| return this.sfxEnabled; | |
| } | |
| }; | |
| } | |
| // Default instance for game.js usage | |
| const audioManager = createAudioManager(); | |
| export default audioManager; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment