1
1
// Audio processing utilities
2
2
export class AudioProcessor {
3
- private audioContext : AudioContext ;
4
- private source : MediaStreamAudioSourceNode ;
3
+ private static instance : AudioProcessor | null = null ;
4
+ private static isInitializing = false ;
5
+ private static processorRegistered = false ;
6
+ private static processorRegistering : Promise < void > | null = null ;
7
+ private audioContext : AudioContext | null = null ;
8
+ private source : MediaStreamAudioSourceNode | null = null ;
5
9
private processor : AudioWorkletNode | MediaRecorder | null = null ;
10
+ private static MAX_RETRIES = 3 ;
11
+ private static RETRY_DELAY = 500 ; // ms
12
+ private stream : MediaStream ;
13
+ private sampleRate : number ;
6
14
7
- constructor ( stream : MediaStream , sampleRate : number = 16000 ) {
15
+ private constructor ( stream : MediaStream , sampleRate : number = 16000 ) {
16
+ this . stream = stream ;
17
+ this . sampleRate = sampleRate ;
18
+ }
19
+
20
+ static async getInstance (
21
+ stream : MediaStream ,
22
+ sampleRate : number = 16000
23
+ ) : Promise < AudioProcessor > {
24
+ if ( AudioProcessor . instance ) {
25
+ // If we have an instance but the context is closed, clean it up
26
+ if ( AudioProcessor . instance . audioContext ?. state === "closed" ) {
27
+ AudioProcessor . instance . cleanup ( ) ;
28
+ AudioProcessor . instance = null ;
29
+ } else {
30
+ return AudioProcessor . instance ;
31
+ }
32
+ }
33
+
34
+ if ( AudioProcessor . isInitializing ) {
35
+ throw new Error ( "AudioProcessor initialization already in progress" ) ;
36
+ }
37
+
38
+ try {
39
+ AudioProcessor . isInitializing = true ;
40
+ AudioProcessor . instance = new AudioProcessor ( stream , sampleRate ) ;
41
+ return AudioProcessor . instance ;
42
+ } finally {
43
+ AudioProcessor . isInitializing = false ;
44
+ }
45
+ }
46
+
47
+ private cleanup ( ) {
48
+ if ( this . processor ) {
49
+ if ( this . processor instanceof AudioWorkletNode ) {
50
+ this . processor . disconnect ( ) ;
51
+ } else if ( this . processor instanceof MediaRecorder ) {
52
+ this . processor . stop ( ) ;
53
+ }
54
+ this . processor = null ;
55
+ }
56
+
57
+ if ( this . source ) {
58
+ this . source . disconnect ( ) ;
59
+ this . source = null ;
60
+ }
61
+
62
+ if ( this . audioContext ) {
63
+ this . audioContext . close ( ) ;
64
+ this . audioContext = null ;
65
+ }
66
+
67
+ AudioProcessor . processorRegistered = false ;
68
+ AudioProcessor . processorRegistering = null ;
69
+ }
70
+
71
+ private async initializeContext ( ) : Promise < void > {
72
+ if ( this . audioContext ?. state === "running" ) return ;
73
+
74
+ // Clean up any existing context
75
+ this . cleanup ( ) ;
76
+
77
+ // Create new context
8
78
this . audioContext = new AudioContext ( {
9
- sampleRate,
79
+ sampleRate : this . sampleRate ,
10
80
latencyHint : "interactive" ,
11
81
} ) ;
12
- this . source = this . audioContext . createMediaStreamSource ( stream ) ;
82
+
83
+ try {
84
+ await this . audioContext . resume ( ) ;
85
+ this . source = this . audioContext . createMediaStreamSource ( this . stream ) ;
86
+ } catch ( error ) {
87
+ console . error ( "[AudioProcessor] Context initialization failed:" , error ) ;
88
+ this . cleanup ( ) ;
89
+ throw error ;
90
+ }
91
+ }
92
+
93
+ private async waitForContext (
94
+ retries = AudioProcessor . MAX_RETRIES
95
+ ) : Promise < void > {
96
+ if ( ! this . audioContext || this . audioContext . state === "closed" ) {
97
+ await this . initializeContext ( ) ;
98
+ }
99
+
100
+ if ( ! this . audioContext ) {
101
+ throw new Error ( "Failed to initialize audio context" ) ;
102
+ }
103
+
104
+ if ( retries <= 0 ) {
105
+ throw new Error ( "Failed to initialize audio context after max retries" ) ;
106
+ }
107
+
108
+ try {
109
+ const state = this . audioContext . state ;
110
+ if ( state === "suspended" || state === "closed" ) {
111
+ await this . audioContext . resume ( ) ;
112
+ const newState = this . audioContext . state ;
113
+ if ( newState === "suspended" || newState === "closed" ) {
114
+ await new Promise ( ( resolve ) =>
115
+ setTimeout ( resolve , AudioProcessor . RETRY_DELAY )
116
+ ) ;
117
+ await this . waitForContext ( retries - 1 ) ;
118
+ }
119
+ }
120
+ } catch ( error ) {
121
+ console . error ( "[AudioProcessor] Context resume failed:" , error ) ;
122
+ await new Promise ( ( resolve ) =>
123
+ setTimeout ( resolve , AudioProcessor . RETRY_DELAY )
124
+ ) ;
125
+ await this . waitForContext ( retries - 1 ) ;
126
+ }
13
127
}
14
128
15
129
async setupProcessor (
16
130
onAudioData : ( data : Blob ) => void ,
17
131
isPaused : boolean = false
18
132
) : Promise < void > {
133
+ if ( this . processor ) {
134
+ console . log ( "[AudioProcessor] Processor already set up" ) ;
135
+ return ;
136
+ }
137
+
19
138
try {
139
+ await this . waitForContext ( ) ;
140
+
141
+ if ( ! this . audioContext || ! this . source ) {
142
+ throw new Error ( "Audio context not properly initialized" ) ;
143
+ }
144
+
20
145
if ( "audioWorklet" in this . audioContext ) {
21
- await this . setupAudioWorklet ( onAudioData , isPaused ) ;
146
+ // Wait for any ongoing registration
147
+ if ( AudioProcessor . processorRegistering ) {
148
+ await AudioProcessor . processorRegistering ;
149
+ }
150
+
151
+ // Register if needed
152
+ if ( ! AudioProcessor . processorRegistered ) {
153
+ AudioProcessor . processorRegistering = this . registerWorkletProcessor (
154
+ this . audioContext
155
+ ) ;
156
+ await AudioProcessor . processorRegistering ;
157
+ AudioProcessor . processorRegistered = true ;
158
+ }
159
+
160
+ await this . setupAudioWorklet ( this . audioContext , onAudioData , isPaused ) ;
22
161
} else {
23
162
this . setupMediaRecorder ( onAudioData , isPaused ) ;
24
163
}
25
164
} catch ( error ) {
26
165
console . error ( "[AudioProcessor] Setup failed:" , error ) ;
166
+ this . cleanup ( ) ;
27
167
throw new Error ( "Failed to initialize audio processing" ) ;
28
168
}
29
169
}
30
170
31
- private async setupAudioWorklet (
32
- onAudioData : ( data : Blob ) => void ,
33
- isPaused : boolean
34
- ) : Promise < void > {
35
- if ( this . audioContext . state === "suspended" ) {
36
- await this . audioContext . resume ( ) ;
37
- }
38
-
171
+ private async registerWorkletProcessor ( context : AudioContext ) : Promise < void > {
39
172
const processorCode = `
40
173
class PCMProcessor extends AudioWorkletProcessor {
41
174
process(inputs, outputs) {
42
175
const input = inputs[0];
43
176
const inputChannel = input[0];
44
177
178
+ if (!inputChannel?.length) return true;
179
+
45
180
// Convert to 16-bit PCM
46
181
const pcmData = new Int16Array(inputChannel.length);
47
182
for (let i = 0; i < inputChannel.length; i++) {
@@ -60,34 +195,50 @@ export class AudioProcessor {
60
195
const blob = new Blob ( [ processorCode ] , { type : "application/javascript" } ) ;
61
196
const url = URL . createObjectURL ( blob ) ;
62
197
63
- await this . audioContext . audioWorklet . addModule ( url ) ;
64
- URL . revokeObjectURL ( url ) ;
198
+ try {
199
+ await context . audioWorklet . addModule ( url ) ;
200
+ } finally {
201
+ URL . revokeObjectURL ( url ) ;
202
+ }
203
+ }
204
+
205
+ private async setupAudioWorklet (
206
+ context : AudioContext ,
207
+ onAudioData : ( data : Blob ) => void ,
208
+ isPaused : boolean
209
+ ) : Promise < void > {
210
+ if ( context . state === "suspended" ) {
211
+ await context . resume ( ) ;
212
+ }
65
213
66
- const workletNode = new AudioWorkletNode (
67
- this . audioContext ,
68
- "pcm-processor"
69
- ) ;
214
+ const workletNode = new AudioWorkletNode ( context , "pcm-processor" ) ;
70
215
workletNode . port . onmessage = ( e : MessageEvent < ArrayBuffer > ) => {
71
216
if ( ! isPaused ) {
72
217
const blob = new Blob ( [ e . data ] , { type : "audio/wav" } ) ;
73
218
onAudioData ( blob ) ;
74
219
}
75
220
} ;
76
221
77
- this . source . connect ( workletNode ) ;
78
- this . processor = workletNode ;
222
+ if ( this . source ) {
223
+ this . source . connect ( workletNode ) ;
224
+ this . processor = workletNode ;
225
+ }
79
226
}
80
227
81
228
private setupMediaRecorder (
82
229
onAudioData : ( data : Blob ) => void ,
83
230
isPaused : boolean
84
231
) : void {
232
+ if ( ! this . source || ! this . audioContext ) {
233
+ throw new Error ( "Audio context or source not initialized" ) ;
234
+ }
235
+
85
236
const recorder = new MediaRecorder ( this . source . mediaStream , {
86
237
mimeType : "audio/webm;codecs=opus" ,
87
238
} ) ;
88
239
89
240
recorder . ondataavailable = async ( e : BlobEvent ) => {
90
- if ( isPaused || ! e . data . size ) return ;
241
+ if ( isPaused || ! e . data . size || ! this . audioContext ) return ;
91
242
92
243
try {
93
244
const arrayBuffer = await e . data . arrayBuffer ( ) ;
@@ -124,13 +275,6 @@ export class AudioProcessor {
124
275
}
125
276
126
277
stop ( ) : void {
127
- if ( this . processor instanceof AudioWorkletNode ) {
128
- this . processor . disconnect ( ) ;
129
- this . source . disconnect ( ) ;
130
- } else if ( this . processor instanceof MediaRecorder ) {
131
- this . processor . stop ( ) ;
132
- }
133
- this . audioContext . close ( ) ;
134
- this . processor = null ;
278
+ this . cleanup ( ) ;
135
279
}
136
280
}
0 commit comments