Ezmary commited on
Commit
36348bf
·
verified ·
1 Parent(s): dea1223

Update src/lib/audio-recorder.ts

Browse files
Files changed (1) hide show
  1. src/lib/audio-recorder.ts +104 -398
src/lib/audio-recorder.ts CHANGED
@@ -1,417 +1,123 @@
1
- /**
2
- * Copyright 2024 Google LLC
3
- *
4
- * Licensed under the Apache License, Version 2.0 (the "License");
5
- * you may not use this file except in compliance with the License.
6
- * You may obtain a copy of the License at
7
- *
8
- * http://www.apache.org/licenses/LICENSE-2.0
9
- *
10
- * Unless required by applicable law or agreed to in writing, software
11
- * distributed under the License is distributed on an "AS IS" BASIS,
12
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- * See the License for the specific language governing permissions and
14
- * limitations under the License.
15
- */
16
 
17
- import { audioContext } from "./utils";
18
- import AudioRecordingWorklet from "./worklets/audio-processing";
19
- import SafariAudioRecordingWorklet from "./worklets/safari-audio-processing";
20
- import VolMeterWorket from "./worklets/vol-meter";
21
-
22
- import { createWorketFromSrc } from "./audioworklet-registry";
23
- import EventEmitter from "eventemitter3";
24
-
25
- function arrayBufferToBase64(buffer: ArrayBuffer) {
26
- var binary = "";
27
- var bytes = new Uint8Array(buffer);
28
- var len = bytes.byteLength;
29
- for (var i = 0; i < len; i++) {
30
- binary += String.fromCharCode(bytes[i]);
31
- }
32
- return window.btoa(binary);
33
- }
34
-
35
- // Add Safari-specific audio context creation
36
- async function createSafariAudioContext(sampleRate: number): Promise<AudioContext> {
37
- console.log('Creating Safari audio context with options:', { sampleRate });
38
-
39
- // Safari requires webkit prefix
40
- const AudioContextClass = (window as any).webkitAudioContext || window.AudioContext;
41
- console.log('Using AudioContext class:', AudioContextClass.name);
42
-
43
- const ctx = new AudioContextClass({
44
- sampleRate,
45
- latencyHint: 'interactive'
46
- });
47
-
48
- console.log('Safari AudioContext initial state:', {
49
- state: ctx.state,
50
- sampleRate: ctx.sampleRate,
51
- baseLatency: ctx.baseLatency,
52
- destination: ctx.destination,
53
- });
54
-
55
- // Safari requires user interaction to start audio context
56
- if (ctx.state === 'suspended') {
57
- console.log('Attempting to resume suspended Safari audio context...');
58
- try {
59
- await ctx.resume();
60
- console.log('Successfully resumed Safari audio context:', ctx.state);
61
- } catch (err) {
62
- console.error('Failed to resume Safari audio context:', err);
63
- throw err;
64
- }
65
- }
66
-
67
- return ctx;
68
- }
69
 
70
  export class AudioRecorder extends EventEmitter {
71
- stream: MediaStream | undefined;
72
- audioContext: AudioContext | undefined;
73
- source: MediaStreamAudioSourceNode | undefined;
74
- recording: boolean = false;
75
- recordingWorklet: AudioWorkletNode | undefined;
76
- vuWorklet: AudioWorkletNode | undefined;
77
-
78
- private starting: Promise<void> | null = null;
79
-
80
- // Add browser detection
81
- isSafari: boolean;
82
- isIOS: boolean;
83
-
84
- constructor(public sampleRate = 16000) {
85
  super();
86
- this.isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
87
- this.isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream;
88
- console.log('AudioRecorder initialized:', {
89
- isSafari: this.isSafari,
90
- isIOS: this.isIOS,
91
- sampleRate: this.sampleRate,
92
- userAgent: navigator.userAgent,
93
- webAudioSupport: !!(window.AudioContext || (window as any).webkitAudioContext),
94
- mediaDevicesSupport: !!navigator.mediaDevices
95
- });
96
  }
97
 
98
- async start() {
99
- if (!navigator.mediaDevices?.getUserMedia) {
100
- console.error('MediaDevices API not available:', {
101
- mediaDevices: !!navigator.mediaDevices,
102
- getUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)
103
- });
104
- throw new Error("Could not request user media");
105
  }
106
-
107
- console.log('Starting AudioRecorder with full environment info:', {
108
- userAgent: navigator.userAgent,
109
- platform: navigator.platform,
110
- vendor: navigator.vendor,
111
- audioWorkletSupport: !!(window.AudioWorklet),
112
- sampleRate: this.sampleRate,
113
- existingAudioContext: !!this.audioContext,
114
- existingStream: !!this.stream,
115
- isSafari: this.isSafari
116
- });
117
-
118
- this.starting = new Promise(async (resolve, reject) => {
119
  try {
120
- if (this.isSafari) {
121
- // Safari implementation
122
- console.log('Safari detected - using Safari-specific audio initialization');
123
-
124
- // 1. First get audio permissions
125
- console.log('Requesting audio permissions first for Safari...');
126
- const constraints = {
127
- audio: {
128
- echoCancellation: false,
129
- noiseSuppression: false,
130
- autoGainControl: false,
131
- sampleRate: this.sampleRate,
132
- channelCount: 1
133
- }
134
- };
135
- console.log('Safari audio constraints:', constraints);
136
-
137
- try {
138
- this.stream = await navigator.mediaDevices.getUserMedia(constraints);
139
- const track = this.stream.getAudioTracks()[0];
140
- console.log('Safari audio permissions granted:', {
141
- track: track.label,
142
- settings: track.getSettings(),
143
- constraints: track.getConstraints(),
144
- enabled: track.enabled,
145
- muted: track.muted,
146
- readyState: track.readyState
147
- });
148
- } catch (err) {
149
- console.error('Failed to get Safari audio permissions:', err);
150
- throw err;
151
- }
152
-
153
- // 2. Create and initialize audio context
154
- try {
155
- this.audioContext = await createSafariAudioContext(this.sampleRate);
156
- console.log('Safari audio context ready:', {
157
- state: this.audioContext.state,
158
- currentTime: this.audioContext.currentTime
159
- });
160
- } catch (err) {
161
- console.error('Failed to initialize Safari audio context:', err);
162
- throw err;
163
- }
164
-
165
- // 3. Create and connect audio source
166
- try {
167
- console.log('Creating Safari audio source...');
168
- this.source = this.audioContext.createMediaStreamSource(this.stream);
169
- console.log('Safari audio source created successfully:', {
170
- numberOfInputs: this.source.numberOfInputs,
171
- numberOfOutputs: this.source.numberOfOutputs,
172
- channelCount: this.source.channelCount
173
- });
174
- } catch (err) {
175
- console.error('Failed to create Safari audio source:', err);
176
- throw err;
177
- }
178
-
179
- // 4. Load and create worklet
180
- try {
181
- const workletName = "audio-recorder-worklet";
182
- console.log('Loading Safari audio worklet...');
183
- const src = createWorketFromSrc(workletName, SafariAudioRecordingWorklet);
184
- await this.audioContext.audioWorklet.addModule(src);
185
- console.log('Safari audio worklet module loaded');
186
-
187
- this.recordingWorklet = new AudioWorkletNode(
188
- this.audioContext,
189
- workletName,
190
- {
191
- numberOfInputs: 1,
192
- numberOfOutputs: 1,
193
- channelCount: 1,
194
- processorOptions: {
195
- sampleRate: this.sampleRate
196
- }
197
- }
198
- );
199
-
200
- // Add detailed error handlers
201
- this.recordingWorklet.onprocessorerror = (event) => {
202
- console.error('Safari AudioWorklet processor error:', event);
203
- };
204
-
205
- this.recordingWorklet.port.onmessageerror = (event) => {
206
- console.error('Safari AudioWorklet message error:', event);
207
- };
208
-
209
- // Add data handler with detailed logging
210
- this.recordingWorklet.port.onmessage = (ev: MessageEvent) => {
211
- const data = ev.data.data;
212
- console.log('Safari AudioWorklet message received:', {
213
- eventType: ev.data.event,
214
- hasData: !!data,
215
- dataType: data ? typeof data : null,
216
- timestamp: Date.now()
217
- });
218
 
219
- if (data?.int16arrayBuffer) {
220
- console.log('Processing Safari audio chunk:', {
221
- byteLength: data.int16arrayBuffer.byteLength,
222
- timestamp: Date.now()
223
- });
224
- const arrayBufferString = arrayBufferToBase64(data.int16arrayBuffer);
225
- this.emit("data", arrayBufferString);
226
- } else {
227
- console.warn('Invalid Safari audio chunk received:', ev.data);
228
- }
229
- };
230
 
231
- console.log('Safari AudioWorkletNode created successfully');
232
- } catch (err) {
233
- console.error('Failed to setup Safari audio worklet:', err);
234
- throw err;
235
- }
236
 
237
- // 5. Connect nodes
238
- try {
239
- console.log('Connecting Safari audio nodes...');
240
- this.source.connect(this.recordingWorklet);
241
- console.log('Safari audio nodes connected successfully');
242
- } catch (err) {
243
- console.error('Failed to connect Safari audio nodes:', err);
244
- throw err;
245
- }
 
 
246
 
247
- } else {
248
- // Chrome/other browsers implementation
249
- console.log('Non-Safari browser detected - using standard audio initialization');
250
-
251
- // Get media stream first for Chrome
252
- const constraints = {
253
- audio: {
254
- echoCancellation: true,
255
- noiseSuppression: true,
256
- autoGainControl: true,
257
- sampleRate: this.sampleRate
258
  }
259
- };
260
- console.log('Chrome audio constraints:', constraints);
261
-
262
- try {
263
- this.stream = await navigator.mediaDevices.getUserMedia(constraints);
264
- const track = this.stream.getAudioTracks()[0];
265
- console.log('Chrome audio permissions granted:', {
266
- track: track.label,
267
- settings: track.getSettings()
268
- });
269
- } catch (err) {
270
- console.error('Failed to get Chrome audio permissions:', err);
271
- throw err;
272
- }
273
-
274
- // Create audio context after getting stream for Chrome
275
- try {
276
- console.log('Creating Chrome audio context...');
277
- this.audioContext = await audioContext({ sampleRate: this.sampleRate });
278
- console.log('Chrome audio context created:', {
279
- state: this.audioContext.state,
280
- sampleRate: this.audioContext.sampleRate
281
- });
282
- } catch (err) {
283
- console.error('Failed to create Chrome audio context:', err);
284
- throw err;
285
- }
286
-
287
- // Create media stream source
288
- try {
289
- console.log('Creating Chrome audio source...');
290
- this.source = this.audioContext.createMediaStreamSource(this.stream);
291
- console.log('Chrome audio source created');
292
- } catch (err) {
293
- console.error('Failed to create Chrome audio source:', err);
294
- throw err;
295
- }
296
-
297
- // Load and create standard worklet
298
- try {
299
- const workletName = "audio-recorder-worklet";
300
- console.log('Loading Chrome audio worklet...');
301
- const src = createWorketFromSrc(workletName, AudioRecordingWorklet);
302
- await this.audioContext.audioWorklet.addModule(src);
303
- console.log('Chrome audio worklet loaded');
304
-
305
- this.recordingWorklet = new AudioWorkletNode(
306
- this.audioContext,
307
- workletName,
308
- {
309
- numberOfInputs: 1,
310
- numberOfOutputs: 1,
311
- channelCount: 1,
312
- processorOptions: {
313
- sampleRate: this.sampleRate
314
- }
315
- }
316
- );
317
-
318
- // Add error handlers
319
- this.recordingWorklet.onprocessorerror = (event) => {
320
- console.error('Chrome AudioWorklet processor error:', event);
321
- };
322
-
323
- this.recordingWorklet.port.onmessageerror = (event) => {
324
- console.error('Chrome AudioWorklet message error:', event);
325
- };
326
-
327
- // Add data handler
328
- this.recordingWorklet.port.onmessage = async (ev: MessageEvent) => {
329
- const arrayBuffer = ev.data.data?.int16arrayBuffer;
330
- if (arrayBuffer) {
331
- const arrayBufferString = arrayBufferToBase64(arrayBuffer);
332
- this.emit("data", arrayBufferString);
333
- } else {
334
- console.warn('Invalid Chrome audio chunk received:', ev.data);
335
- }
336
- };
337
-
338
- console.log('Chrome AudioWorkletNode created');
339
- } catch (err) {
340
- console.error('Failed to setup Chrome audio worklet:', err);
341
- throw err;
342
- }
343
-
344
- // Connect nodes
345
- try {
346
- console.log('Connecting Chrome audio nodes...');
347
- this.source.connect(this.recordingWorklet);
348
- console.log('Chrome audio nodes connected');
349
-
350
- // Set up VU meter
351
- const vuWorkletName = "vu-meter";
352
- await this.audioContext.audioWorklet.addModule(
353
- createWorketFromSrc(vuWorkletName, VolMeterWorket),
354
- );
355
- this.vuWorklet = new AudioWorkletNode(this.audioContext, vuWorkletName);
356
- this.vuWorklet.port.onmessage = (ev: MessageEvent) => {
357
- this.emit("volume", ev.data.volume);
358
- };
359
- this.source.connect(this.vuWorklet);
360
- console.log('Chrome VU meter connected');
361
- } catch (err) {
362
- console.error('Failed to connect Chrome audio nodes:', err);
363
- throw err;
364
- }
365
- }
366
 
367
- this.recording = true;
368
- console.log('Recording started successfully');
369
- resolve();
370
- this.starting = null;
371
- } catch (error) {
372
- console.error('Failed to start recording:', error);
373
- this.stop();
374
- reject(error);
375
- this.starting = null;
376
- }
377
- });
378
- return this.starting;
379
  }
380
 
381
- stop() {
382
- console.log('Stopping audio recorder...');
383
- // its plausible that stop would be called before start completes
384
- // such as if the websocket immediately hangs up
385
- const handleStop = () => {
386
- try {
387
- if (this.source) {
388
- console.log('Disconnecting audio source...');
389
- this.source.disconnect();
390
- }
391
- if (this.stream) {
392
- console.log('Stopping media stream tracks...');
393
- this.stream.getTracks().forEach(track => {
394
- track.stop();
395
- console.log('Stopped track:', track.label);
396
- });
397
- }
398
- if (this.audioContext && this.isSafari) {
399
- console.log('Closing Safari audio context...');
400
- this.audioContext.close();
401
- }
402
- this.stream = undefined;
403
- this.recordingWorklet = undefined;
404
- this.vuWorklet = undefined;
405
- console.log('Audio recorder stopped successfully');
406
- } catch (err) {
407
- console.error('Error while stopping audio recorder:', err);
408
- }
409
- };
410
- if (this.starting) {
411
- console.log('Stop called while starting - waiting for start to complete...');
412
- this.starting.then(handleStop);
413
  return;
414
  }
415
- handleStop();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
  }
417
- }
 
1
+ // src/lib/audio-recorder.ts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
+ import EventEmitter from 'eventemitter3';
4
+ import { audioContext } from './utils';
5
+ import VolMeterWorket from './worklets/vol-meter';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  export class AudioRecorder extends EventEmitter {
8
+ public recording = false;
9
+ private mediaStream: MediaStream | null = null;
10
+ private audioContext: AudioContext | null = null;
11
+ private processor: ScriptProcessorNode | null = null;
12
+ private source: MediaStreamAudioSourceNode | null = null;
13
+ private volMeter: AudioWorkletNode | null = null;
14
+
15
+ constructor() {
 
 
 
 
 
 
16
  super();
 
 
 
 
 
 
 
 
 
 
17
  }
18
 
19
+ private async init() {
20
+ if (!this.audioContext) {
21
+ this.audioContext = await audioContext({ id: 'audio-in' });
 
 
 
 
22
  }
23
+ if (!this.mediaStream) {
 
 
 
 
 
 
 
 
 
 
 
 
24
  try {
25
+ this.mediaStream = await navigator.mediaDevices.getUserMedia({
26
+ audio: {
27
+ sampleRate: 16000,
28
+ channelCount: 1,
29
+ echoCancellation: true,
30
+ noiseSuppression: true,
31
+ },
32
+ video: false,
33
+ });
34
+ } catch (err) {
35
+ console.error('Error getting media stream:', err);
36
+ this.emit('error', err);
37
+ return;
38
+ }
39
+ }
40
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
+ public async start() {
43
+ if (this.recording) {
44
+ return;
45
+ }
46
+
47
+ await this.init();
 
 
 
 
 
48
 
49
+ if (!this.audioContext || !this.mediaStream) {
50
+ console.error("AudioContext or MediaStream not available.");
51
+ return;
52
+ }
 
53
 
54
+ this.recording = true;
55
+ this.source = this.audioContext.createMediaStreamSource(this.mediaStream);
56
+ this.processor = this.audioContext.createScriptProcessor(1024, 1, 1);
57
+
58
+ this.processor.onaudioprocess = (e) => {
59
+ if (!this.recording) return;
60
+ const inputData = e.inputBuffer.getChannelData(0);
61
+ const pcm16 = this.floatTo16BitPCM(inputData);
62
+ const base64 = this.pcm16ToBase64(pcm16);
63
+ this.emit('data', base64);
64
+ };
65
 
66
+ // --- بخش کلیدی: اضافه کردن Volume Meter ---
67
+ try {
68
+ await this.audioContext.audioWorklet.addModule(VolMeterWorket);
69
+ this.volMeter = new AudioWorkletNode(this.audioContext, 'vumeter');
70
+ this.volMeter.port.onmessage = (event) => {
71
+ if(event.data.volume) {
72
+ // ارسال رویداد جدید به همراه حجم صدا
73
+ this.emit('volume', event.data.volume);
 
 
 
74
  }
75
+ };
76
+ this.source.connect(this.volMeter);
77
+ } catch(err) {
78
+ console.error("Error adding AudioWorklet module", err);
79
+ }
80
+ // ------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
 
82
+ this.source.connect(this.processor);
83
+ this.processor.connect(this.audioContext.destination);
84
+
85
+ this.emit('start');
 
 
 
 
 
 
 
 
86
  }
87
 
88
+ public stop() {
89
+ if (!this.recording) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  return;
91
  }
92
+ this.recording = false;
93
+ this.emit('stop');
94
+
95
+ this.source?.disconnect();
96
+ this.processor?.disconnect();
97
+ this.volMeter?.disconnect();
98
+
99
+ this.source = null;
100
+ this.processor = null;
101
+ this.volMeter = null;
102
+ }
103
+
104
+ // توابع کمکی برای تبدیل فرمت صدا
105
+ private floatTo16BitPCM(input: Float32Array): Int16Array {
106
+ const output = new Int16Array(input.length);
107
+ for (let i = 0; i < input.length; i++) {
108
+ const s = Math.max(-1, Math.min(1, input[i]));
109
+ output[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
110
+ }
111
+ return output;
112
+ }
113
+
114
+ private pcm16ToBase64(pcm16: Int16Array): string {
115
+ const buffer = pcm16.buffer;
116
+ const bytes = new Uint8Array(buffer);
117
+ let binary = '';
118
+ for (let i = 0; i < bytes.byteLength; i++) {
119
+ binary += String.fromCharCode(bytes[i]);
120
+ }
121
+ return btoa(binary);
122
  }
123
+ }