mmy / lib /AudioManager.ts
Mohammad Shahid
updated some stuff
c408dda
// 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<string, AudioBuffer> = 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<void> {
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();