// Define the webkitAudioContext for older browser compatibility (e.g., Safari) interface Window { webkitAudioContext?: typeof AudioContext; } /** * Manages all audio playback and recording stream routing for the application. * This is a singleton class, so you should import the `audioManager` instance. */ class AudioManager { private audioContext: AudioContext | null = null; private audioBuffers: Map = new Map(); private isInitialized = false; // This node will be the destination for all sounds that need to be recorded. private mediaStreamDestination: MediaStreamAudioDestinationNode | null = null; /** * Initializes the AudioContext. Must be called before any other methods. * This is safe to call multiple times. */ public initialize() { if (this.isInitialized || this.audioContext) return; try { // Create a new AudioContext, using the webkit fallback if necessary. this.audioContext = new (window.AudioContext || (window as Window).webkitAudioContext)(); this.isInitialized = true; console.log("[AudioManager] AudioContext Initialized."); } catch (e) { console.error("[AudioManager] Web Audio API is not supported in this browser.", e); } } /** * Resumes the AudioContext if it's suspended. This must be called from * within a user interaction event (e.g., a click handler). */ public unlockAudio() { if (this.audioContext && this.audioContext.state === 'suspended') { this.audioContext.resume().then(() => { console.log("[AudioManager] AudioContext resumed successfully."); }).catch(e => console.error("[AudioManager] Error resuming AudioContext:", e)); } } /** * Fetches an audio file and decodes it into an AudioBuffer. * @param name - A unique key to identify the sound. * @param url - The URL of the audio file. */ public async loadSound(name: string, url: string): Promise { if (!this.audioContext) { console.error("[AudioManager] AudioContext not initialized. Cannot load sound."); return Promise.reject("AudioContext not initialized"); } if (this.audioBuffers.has(name)) return Promise.resolve(); try { const response = await fetch(url, { mode: 'cors' }); const arrayBuffer = await response.arrayBuffer(); const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer); this.audioBuffers.set(name, audioBuffer); // console.log(`[AudioManager] Sound loaded: ${name}`); } catch (e) { console.error(`[AudioManager] Failed to load sound: ${name} from ${url}`, e); return Promise.reject(`Failed to load sound: ${name}`); } } /** * **[NEW METHOD]** * Retrieves a pre-loaded AudioBuffer by its name. * This is useful for getting metadata like the duration of a sound. * @param name - The key of the sound to retrieve. * @returns The AudioBuffer if found, otherwise null. */ public getSoundBuffer(name: string): AudioBuffer | null { return this.audioBuffers.get(name) || null; } /** * Plays a pre-loaded sound. * @param name - The key of the sound to play. * @param volume - The volume to play the sound at (0.0 to 1.0). */ public playSound(name: string, volume: number = 1) { if (!this.audioContext || !this.audioBuffers.has(name)) { // This can be noisy, so it's commented out, but useful for debugging. // console.warn(`[AudioManager] Sound not found or context not ready: ${name}`); return; } // Create a source node for the sound buffer. const source = this.audioContext.createBufferSource(); source.buffer = this.audioBuffers.get(name)!; // Create a GainNode to control the volume of this specific sound instance. const gainNode = this.audioContext.createGain(); gainNode.gain.value = volume; // The audio signal path: // Source -> GainNode -> ... source.connect(gainNode); // ... -> Main Output (Speakers) gainNode.connect(this.audioContext.destination); // ... -> Recording Stream (if active) if (this.mediaStreamDestination) { gainNode.connect(this.mediaStreamDestination); } source.start(0); } // --- RECORDING STREAM LIFECYCLE METHODS --- /** * **[IMPORTANT]** Creates the audio destination node for recording. * This must be called *before* you intend to start recording and *before* * any sounds that need to be recorded are played. */ public startAudioStreamCapture() { if (!this.audioContext) { console.error("[AudioManager] AudioContext not initialized. Cannot start stream capture."); return; } if (!this.mediaStreamDestination) { console.log("[AudioManager] Creating new MediaStreamAudioDestinationNode for recording."); this.mediaStreamDestination = this.audioContext.createMediaStreamDestination(); } } /** * Retrieves the audio stream for the MediaRecorder. * `startAudioStreamCapture` must have been called before this. * @returns A MediaStream containing the audio, or null if not ready. */ public getAudioStream(): MediaStream | null { if (!this.mediaStreamDestination) { console.error( "[AudioManager] Audio stream capture was not started. Call startAudioStreamCapture() first." ); return null; } return this.mediaStreamDestination.stream; } /** * Cleans up the recording stream destination. * Call this when the recording is finished. */ public stopAudioStreamCapture() { if (this.mediaStreamDestination) { // Nullifying the reference is enough for it to be garbage collected. this.mediaStreamDestination = null; console.log("[AudioManager] Cleared MediaStreamAudioDestinationNode."); } } } // Export a single instance to be used throughout the application. export const audioManager = new AudioManager();