File size: 6,423 Bytes
c408dda
3a7a84c
 
 
 
c408dda
 
 
 
3a7a84c
 
c408dda
3a7a84c
 
c408dda
 
 
 
 
 
 
 
3a7a84c
 
c408dda
 
3a7a84c
c408dda
3a7a84c
c408dda
3a7a84c
 
c408dda
 
 
 
 
 
3a7a84c
 
c408dda
 
3a7a84c
 
c408dda
 
 
 
 
 
 
3a7a84c
c408dda
3a7a84c
 
c408dda
3a7a84c
 
 
 
 
c408dda
 
3a7a84c
c408dda
3a7a84c
 
 
 
c408dda
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3a7a84c
 
 
c408dda
3a7a84c
c408dda
 
 
 
 
 
 
 
 
 
 
 
3a7a84c
c408dda
3a7a84c
c408dda
3a7a84c
 
 
 
c408dda
 
 
 
 
 
 
 
 
3a7a84c
c408dda
 
3a7a84c
 
c408dda
3a7a84c
 
c408dda
 
 
 
 
 
 
 
 
 
 
 
 
 
3a7a84c
 
c408dda
 
 
 
 
 
 
 
 
 
 
3a7a84c
 
 
c408dda
3a7a84c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
// 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();