File size: 4,345 Bytes
36348bf
7f2a14a
0362c08
59d8c15
 
0362c08
59d8c15
 
7f2a14a
59d8c15
 
 
 
 
 
 
 
 
 
 
 
29d8bf1
59d8c15
29d8bf1
36348bf
59d8c15
 
7f2a14a
59d8c15
29d8bf1
 
 
59d8c15
29d8bf1
 
59d8c15
 
 
 
 
 
7f2a14a
 
59d8c15
29d8bf1
59d8c15
 
29d8bf1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cdd3d29
29d8bf1
cdd3d29
29d8bf1
 
 
cdd3d29
29d8bf1
cdd3d29
29d8bf1
 
 
59d8c15
 
 
 
 
 
29d8bf1
59d8c15
 
 
 
36348bf
 
59d8c15
29d8bf1
59d8c15
cdd3d29
29d8bf1
 
 
 
59d8c15
cdd3d29
29d8bf1
 
59d8c15
 
 
 
 
29d8bf1
 
 
36348bf
7f2a14a
36348bf
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
// src/lib/audio-recorder.ts

import { audioContext } from "./utils";
import AudioRecordingWorklet from "./worklets/audio-processing";
import SafariAudioRecordingWorklet from "./worklets/safari-audio-processing";
import VolMeterWorket from "./worklets/vol-meter";
import { createWorketFromSrc } from "./audioworklet-registry";
import EventEmitter from "eventemitter3";

function arrayBufferToBase64(buffer: ArrayBuffer) {
  var binary = "";
  var bytes = new Uint8Array(buffer);
  var len = bytes.byteLength;
  for (var i = 0; i < len; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return window.btoa(binary);
}

async function createSafariAudioContext(sampleRate: number): Promise<AudioContext> {
  const AudioContextClass = (window as any).webkitAudioContext || window.AudioContext;
  const ctx = new AudioContextClass({ sampleRate, latencyHint: 'interactive' });
  if (ctx.state === 'suspended') {
    await ctx.resume();
  }
  return ctx;
}

export class AudioRecorder extends EventEmitter {
  stream?: MediaStream;
  audioContext?: AudioContext;
  source?: MediaStreamAudioSourceNode;
  recording: boolean = false;
  recordingWorklet?: AudioWorkletNode;
  vuWorklet?: AudioWorkletNode;
  private starting: Promise<void> | null = null;
  isSafari: boolean;

  constructor(public sampleRate = 16000) {
    super();
    this.isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
  }

  async start() {
    if (this.recording || this.starting) return;
    this.starting = new Promise(async (resolve, reject) => {
      try {
        const constraints = {
          audio: this.isSafari 
            ? { sampleRate: this.sampleRate, channelCount: 1 } 
            : { echoCancellation: true, noiseSuppression: true, autoGainControl: true, sampleRate: this.sampleRate }
        };
        this.stream = await navigator.mediaDevices.getUserMedia(constraints);
        
        this.audioContext = this.isSafari 
            ? await createSafariAudioContext(this.sampleRate)
            : await audioContext({ sampleRate: this.sampleRate });

        this.source = this.audioContext.createMediaStreamSource(this.stream);

        const recordingWorkletSrc = this.isSafari ? SafariAudioRecordingWorklet : AudioRecordingWorklet;
        await this.audioContext.audioWorklet.addModule(createWorketFromSrc("recorder", recordingWorkletSrc));
        await this.audioContext.audioWorklet.addModule(createWorketFromSrc("vumeter", VolMeterWorket));

        this.recordingWorklet = new AudioWorkletNode(this.audioContext, "recorder", { processorOptions: { sampleRate: this.sampleRate } });
        this.vuWorklet = new AudioWorkletNode(this.audioContext, "vumeter");

        let lastVolume = 0;
        
        this.recordingWorklet.port.onmessage = (ev: MessageEvent) => {
          const arrayBuffer = ev.data.data?.int16arrayBuffer;
          if (arrayBuffer) {
            const base64 = arrayBufferToBase64(arrayBuffer);
            // ارسال همزمان دیتا و آخرین حجم صدای دریافتی
            this.emit("data", base64, lastVolume);
          }
        };

        this.vuWorklet.port.onmessage = (ev: MessageEvent) => {
          if (typeof ev.data.volume === 'number') {
            lastVolume = ev.data.volume;
          }
        };

        this.source.connect(this.recordingWorklet);
        this.source.connect(this.vuWorklet);
        
        this.recording = true;
        resolve();
      } catch (error) {
        console.error('Failed to start recording:', error);
        this.stop();
        reject(error);
      } finally {
        this.starting = null;
      }
    });
    return this.starting;
  }

  stop() {
    if (!this.recording && !this.starting) return;
    const handleStop = () => {
        this.recording = false;
        this.source?.disconnect();
        this.stream?.getTracks().forEach(track => track.stop());
        if (this.audioContext?.state === 'running') {
            this.audioContext.close();
        }
        this.stream = undefined;
        this.audioContext = undefined;
        this.source = undefined;
        this.recordingWorklet = undefined;
        this.vuWorklet = undefined;
        this.emit("stop");
    };
    if (this.starting) {
      this.starting.then(handleStop).catch(handleStop);
    } else {
      handleStop();
    }
  }
}