recorder-client 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,7 @@ npm i recorder-client
28
28
  | onpause | () => void | pause event |
29
29
  | onresume | () => void | resume event |
30
30
  | onstart | () => void | start event |
31
- | onstop | (pcm: Int16Array) => void | stop event |
31
+ | onstop | () => void | stop event |
32
32
  | pause | () => void | audio pause |
33
33
  | resume | () => void | audio resume |
34
34
  | start | () => void | audio start |
@@ -57,7 +57,7 @@ const recorder = new Recorder({
57
57
  recorder.onstart = () => {};
58
58
  recorder.onpause = () => {};
59
59
  recorder.onresume = () => {};
60
- recorder.onstop = (pcm) => {};
60
+ recorder.onstop = () => {};
61
61
  recorder.ondataavailable = (pcm) => {};
62
62
 
63
63
  const onClick = () => {
@@ -115,7 +115,7 @@ recorder.ondataavailable = (pcm) => {
115
115
  pcms.push(pcm);
116
116
  };
117
117
 
118
- const onMerge = () => {
118
+ recorder.onstop = () => {
119
119
  const data = Recorder.pcmMerge(pcms);
120
120
  };
121
121
  ```
@@ -130,8 +130,15 @@ const recorder = new Recorder({
130
130
  chunkSize: 1900,
131
131
  });
132
132
 
133
- recorder.onstop = (pcm) => {
134
- const blob = pcmToWav(pcm, 16000);
133
+ const pcms: Int16Array[] = [];
134
+
135
+ recorder.ondataavailable = (pcm) => {
136
+ pcms.push(pcm);
137
+ };
138
+
139
+ recorder.onstop = () => {
140
+ const data = Recorder.pcmMerge(pcms);
141
+ const blob = Recorder.pcmToWav(pcm, 16000);
135
142
  };
136
143
  ```
137
144
 
package/dist/index.d.ts CHANGED
@@ -1,53 +1,52 @@
1
+ //#region src/index.d.ts
1
2
  /** 预定义的VAD场景类型 */
2
3
  type VadScene = 'extreme-noise' | 'high-noise' | 'live-stream' | 'normal-indoor' | 'quiet-studio' | 'voice-assistant';
3
4
  /** VAD配置选项类型 */
4
5
  interface VadOptions {
5
- noiseCoefficient: number;
6
- voiceRatioThreshold: number;
7
- noiseFrameCount: number;
8
- frameDurationMs?: number;
6
+ noiseCoefficient: number;
7
+ voiceRatioThreshold: number;
8
+ noiseFrameCount: number;
9
+ frameDurationMs?: number;
9
10
  }
10
11
  type Vad = Partial<VadOptions> | VadScene;
11
12
  interface RecorderConstructor {
12
- sampleRate: number;
13
- chunkSize: number;
14
- vad?: Vad;
13
+ sampleRate: number;
14
+ chunkSize: number;
15
+ vad?: Vad;
15
16
  }
16
17
  declare class Recorder {
17
- private _stream?;
18
- private _audioContext?;
19
- private _source?;
20
- private _workletNode?;
21
- private _pcmData;
22
- private readonly _options;
23
- ondataavailable?: (data: Int16Array) => void;
24
- onpause?: () => void;
25
- onresume?: () => void;
26
- onstart?: () => void;
27
- onstop?: (data: Int16Array) => void;
28
- constructor(options: RecorderConstructor);
29
- start(): Promise<void>;
30
- pause(): void;
31
- resume(): void;
32
- stop(): void;
33
- static pcmMerge(arrays: Int16Array[]): Int16Array;
34
- static pcmToWav(data: Int16Array, sampleRate: number): Blob;
35
- /**
36
- * 语音活动检测(VAD):判断音频片段是否包含说话内容
37
- * @param int16Array 原始PCM音频数据(16位整型)
38
- * @param sampleRate 采样率(如16000、44100)
39
- * @param vad 场景类型或自定义配置
40
- * @returns 是否检测到语音
41
- */
42
- static isSpeak(int16Array: Int16Array, sampleRate: number, vad?: Vad): boolean;
43
- static parseWav(buffer: ArrayBuffer): {
44
- numChannels: number;
45
- sampleRate: number;
46
- byteRate: number;
47
- fileSize: number;
48
- audioFormat: number;
49
- };
18
+ private _stream?;
19
+ private _audioContext?;
20
+ private _source?;
21
+ private _workletNode?;
22
+ private readonly _options;
23
+ ondataavailable?: (data: Int16Array) => void;
24
+ onpause?: () => void;
25
+ onresume?: () => void;
26
+ onstart?: () => void;
27
+ onstop?: () => void;
28
+ constructor(options: RecorderConstructor);
29
+ start(): Promise<void>;
30
+ pause(): void;
31
+ resume(): void;
32
+ stop(): void;
33
+ static pcmMerge(arrays: Int16Array[]): Int16Array;
34
+ static pcmToWav(data: Int16Array, sampleRate: number): Blob;
35
+ /**
36
+ * 语音活动检测(VAD):判断音频片段是否包含说话内容
37
+ * @param int16Array 原始PCM音频数据(16位整型)
38
+ * @param sampleRate 采样率(如16000、44100)
39
+ * @param vad 场景类型或自定义配置
40
+ * @returns 是否检测到语音
41
+ */
42
+ static isSpeak(int16Array: Int16Array, sampleRate: number, vad?: Vad): boolean;
43
+ static parseWav(buffer: ArrayBuffer): {
44
+ numChannels: number;
45
+ sampleRate: number;
46
+ byteRate: number;
47
+ fileSize: number;
48
+ audioFormat: number;
49
+ };
50
50
  }
51
-
52
- export { Recorder };
53
- export type { RecorderConstructor, Vad, VadOptions, VadScene };
51
+ //#endregion
52
+ export { Recorder, RecorderConstructor, Vad, VadOptions, VadScene };
package/dist/index.js ADDED
@@ -0,0 +1,271 @@
1
+ //#region src/index.ts
2
+ function audioProcessorJs() {
3
+ const blob = new Blob([`
4
+ class AudioProcessor extends AudioWorkletProcessor {
5
+ constructor({ processorOptions }) {
6
+ super();
7
+ this.pcmBuffer = new Int16Array(0);
8
+ this.chunkSize = processorOptions.chunkSize;
9
+ this.port.onmessage = (event) => {
10
+ const { data } = event;
11
+ if (data === 'pause') {
12
+ this.isPaused = true;
13
+ } else if (data === 'resume') {
14
+ this.isPaused = false;
15
+ } else if (data === 'stop') {
16
+ this.isPaused = true;
17
+ }
18
+ };
19
+ }
20
+
21
+ process(inputs, outputs, parameters) {
22
+ if (this.isPaused) {
23
+ return true;
24
+ }
25
+ const input = inputs[0]; // 单声道输入
26
+ const buffer = input[0]; // Float32Array
27
+ const length = buffer.length;
28
+
29
+ // 转换为 16 位 PCM
30
+ const pcm = new Int16Array(length);
31
+ for (let i = 0; i < length; i++) {
32
+ pcm[i] = Math.min(1, buffer[i]) * 32767; // 量化到 Int16
33
+ }
34
+
35
+ // 累积数据
36
+ const newLength = this.pcmBuffer.length + length;
37
+ const newBuffer = new Int16Array(newLength);
38
+ newBuffer.set(this.pcmBuffer);
39
+ newBuffer.set(pcm, this.pcmBuffer.length);
40
+ this.pcmBuffer = newBuffer;
41
+
42
+ // 检查是否达到目标大小
43
+ if (this.pcmBuffer.byteLength >= this.chunkSize) {
44
+ // 提取指定之前的数据
45
+ const dataToSent = this.pcmBuffer.slice(0, this.chunkSize / 2); // 1900 字节 = 950 个 Int16
46
+ this.port.postMessage({ e: 'data', data: dataToSent });
47
+ // 保留剩余数据
48
+ this.pcmBuffer = this.pcmBuffer.slice(this.chunkSize / 2);
49
+ }
50
+
51
+ return true;
52
+ }
53
+ }
54
+
55
+ registerProcessor('audio-processor', AudioProcessor);
56
+ `], { type: "application/javascript" });
57
+ return URL.createObjectURL(blob);
58
+ }
59
+ /** 场景化预设配置表 */
60
+ const SCENE_PRESETS = {
61
+ "quiet-studio": {
62
+ noiseCoefficient: 1.6,
63
+ voiceRatioThreshold: .4,
64
+ noiseFrameCount: 3,
65
+ frameDurationMs: 20
66
+ },
67
+ "normal-indoor": {
68
+ noiseCoefficient: 2.3,
69
+ voiceRatioThreshold: .5,
70
+ noiseFrameCount: 5,
71
+ frameDurationMs: 20
72
+ },
73
+ "high-noise": {
74
+ noiseCoefficient: 3.8,
75
+ voiceRatioThreshold: .6,
76
+ noiseFrameCount: 8,
77
+ frameDurationMs: 15
78
+ },
79
+ "extreme-noise": {
80
+ noiseCoefficient: 4.5,
81
+ voiceRatioThreshold: .7,
82
+ noiseFrameCount: 10,
83
+ frameDurationMs: 10
84
+ },
85
+ "voice-assistant": {
86
+ noiseCoefficient: 2,
87
+ voiceRatioThreshold: .3,
88
+ noiseFrameCount: 2,
89
+ frameDurationMs: 20
90
+ },
91
+ "live-stream": {
92
+ noiseCoefficient: 2.8,
93
+ voiceRatioThreshold: .45,
94
+ noiseFrameCount: 6,
95
+ frameDurationMs: 20
96
+ }
97
+ };
98
+ var Recorder = class Recorder {
99
+ _stream;
100
+ _audioContext;
101
+ _source;
102
+ _workletNode;
103
+ _options;
104
+ ondataavailable;
105
+ onpause;
106
+ onresume;
107
+ onstart;
108
+ onstop;
109
+ constructor(options) {
110
+ this._options = options;
111
+ }
112
+ async start() {
113
+ const { sampleRate, chunkSize, vad } = this._options;
114
+ try {
115
+ this._stream = await navigator.mediaDevices.getUserMedia({ audio: true });
116
+ this._audioContext = new AudioContext({ sampleRate });
117
+ this._source = this._audioContext.createMediaStreamSource(this._stream);
118
+ await this._audioContext.audioWorklet.addModule(audioProcessorJs(), { credentials: "include" });
119
+ this._workletNode = new AudioWorkletNode(this._audioContext, "audio-processor", {
120
+ channelCount: 1,
121
+ channelCountMode: "explicit",
122
+ channelInterpretation: "discrete",
123
+ processorOptions: { chunkSize }
124
+ });
125
+ this._source.connect(this._workletNode);
126
+ this._workletNode.connect(this._audioContext.destination);
127
+ this._workletNode.port.onmessage = (event) => {
128
+ const { e, data } = event.data;
129
+ if (e === "data") if (vad) {
130
+ if (Recorder.isSpeak(data, sampleRate, vad)) this.ondataavailable?.(data);
131
+ } else this.ondataavailable?.(data);
132
+ };
133
+ this.onstart?.();
134
+ } catch (error) {
135
+ console.warn(error);
136
+ throw error;
137
+ }
138
+ }
139
+ pause() {
140
+ this._workletNode?.port.postMessage("pause");
141
+ this.onpause?.();
142
+ }
143
+ resume() {
144
+ this._workletNode?.port.postMessage("resume");
145
+ this.onresume?.();
146
+ }
147
+ stop() {
148
+ this._workletNode?.port.postMessage("stop");
149
+ this._stream?.getTracks().forEach((track) => {
150
+ track.stop();
151
+ });
152
+ this._source?.disconnect();
153
+ this._workletNode?.disconnect();
154
+ this._stream = void 0;
155
+ this._source = void 0;
156
+ this._workletNode = void 0;
157
+ this._audioContext = void 0;
158
+ this.onstop?.();
159
+ }
160
+ static pcmMerge(arrays) {
161
+ const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
162
+ const merged = new Int16Array(totalLength);
163
+ let offset = 0;
164
+ for (const arr of arrays) {
165
+ merged.set(arr, offset);
166
+ offset += arr.length;
167
+ }
168
+ return merged;
169
+ }
170
+ static pcmToWav(data, sampleRate) {
171
+ function writeString(view, offset, type) {
172
+ for (let i = 0; i < type.length; i++) view.setUint8(offset + i, type.charCodeAt(i));
173
+ }
174
+ const numChannels = 1;
175
+ const bytesPerSample = 2;
176
+ const dataLength = data.length;
177
+ const fileSize = 44 + dataLength * bytesPerSample;
178
+ const buffer = new ArrayBuffer(fileSize);
179
+ const view = new DataView(buffer);
180
+ writeString(view, 0, "RIFF");
181
+ view.setUint32(4, fileSize - 8, true);
182
+ writeString(view, 8, "WAVE");
183
+ writeString(view, 12, "fmt ");
184
+ view.setUint32(16, 16, true);
185
+ view.setUint16(20, 1, true);
186
+ view.setUint16(22, numChannels, true);
187
+ view.setUint32(24, sampleRate, true);
188
+ view.setUint32(28, sampleRate * numChannels * bytesPerSample, true);
189
+ view.setUint16(32, numChannels * bytesPerSample, true);
190
+ view.setUint16(34, bytesPerSample * 8, true);
191
+ writeString(view, 36, "data");
192
+ view.setUint32(40, dataLength * bytesPerSample, true);
193
+ for (let i = 0; i < dataLength; i++) view.setInt16(44 + i * bytesPerSample, data[i], true);
194
+ return new Blob([buffer], { type: "audio/wav" });
195
+ }
196
+ /**
197
+ * 语音活动检测(VAD):判断音频片段是否包含说话内容
198
+ * @param int16Array 原始PCM音频数据(16位整型)
199
+ * @param sampleRate 采样率(如16000、44100)
200
+ * @param vad 场景类型或自定义配置
201
+ * @returns 是否检测到语音
202
+ */
203
+ static isSpeak(int16Array, sampleRate, vad) {
204
+ let options;
205
+ if (typeof vad === "string") options = { ...SCENE_PRESETS[vad] };
206
+ else options = {
207
+ ...SCENE_PRESETS["normal-indoor"],
208
+ ...vad
209
+ };
210
+ const frameSize = Math.floor((options.frameDurationMs ?? 20) * sampleRate / 1e3);
211
+ const totalFrameCount = Math.ceil(int16Array.length / frameSize);
212
+ let speakingFrameCount = 0;
213
+ let noiseRmsSum = 0;
214
+ const actualNoiseFrameCount = Math.min(options.noiseFrameCount, totalFrameCount);
215
+ for (let i = 0; i < actualNoiseFrameCount; i++) {
216
+ const start = i * frameSize;
217
+ const end = Math.min(start + frameSize, int16Array.length);
218
+ const frame = int16Array.subarray(start, end);
219
+ let sum = 0;
220
+ for (let j = 0; j < frame.length; j++) {
221
+ const sample = frame[j] / 32768;
222
+ sum += sample * sample;
223
+ }
224
+ noiseRmsSum += Math.sqrt(sum / frame.length);
225
+ }
226
+ const dynamicThreshold = noiseRmsSum / actualNoiseFrameCount * options.noiseCoefficient;
227
+ for (let i = 0; i < totalFrameCount; i++) {
228
+ const start = i * frameSize;
229
+ const end = Math.min(start + frameSize, int16Array.length);
230
+ const frame = int16Array.subarray(start, end);
231
+ let sum = 0;
232
+ for (let j = 0; j < frame.length; j++) {
233
+ const sample = frame[j] / 32768;
234
+ sum += sample * sample;
235
+ }
236
+ if (Math.sqrt(sum / frame.length) > dynamicThreshold) speakingFrameCount++;
237
+ }
238
+ return speakingFrameCount / totalFrameCount > options.voiceRatioThreshold;
239
+ }
240
+ static parseWav(buffer) {
241
+ function uint32ToChars(uint32) {
242
+ return String.fromCharCode(uint32 >> 24 & 255, uint32 >> 16 & 255, uint32 >> 8 & 255, uint32 >> 0 & 255);
243
+ }
244
+ const dataView = new DataView(buffer);
245
+ let offset = 0;
246
+ if (dataView.getUint32(offset, false) !== 1380533830) throw new Error("Not a RIFF file");
247
+ offset += 4;
248
+ const fileSize = dataView.getUint32(offset, true);
249
+ offset += 4;
250
+ if (dataView.getUint32(offset, false) !== 1463899717) throw new Error("Not a WAVE file");
251
+ offset += 4;
252
+ while (offset < buffer.byteLength) {
253
+ const chunkId = dataView.getUint32(offset, false);
254
+ const chunkSize = dataView.getUint32(offset + 4, true);
255
+ if (uint32ToChars(chunkId) === "fmt ") {
256
+ const audioFormat = dataView.getUint16(offset + 8, true);
257
+ return {
258
+ numChannels: dataView.getUint16(offset + 10, true),
259
+ sampleRate: dataView.getUint32(offset + 12, true),
260
+ byteRate: dataView.getUint32(offset + 16, true),
261
+ fileSize,
262
+ audioFormat
263
+ };
264
+ }
265
+ offset += 8 + chunkSize;
266
+ }
267
+ throw new Error("fmt chunk not found");
268
+ }
269
+ };
270
+ //#endregion
271
+ export { Recorder };
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "recorder-client",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "audio recording client",
5
5
  "type": "module",
6
- "main": "dist/index.cjs.js",
7
- "module": "dist/index.esm.js",
6
+ "main": "dist/index.js",
7
+ "module": "dist/index.js",
8
8
  "types": "dist/index.d.ts",
9
9
  "files": [
10
10
  "dist"
@@ -12,28 +12,25 @@
12
12
  "exports": {
13
13
  ".": {
14
14
  "types": "./dist/index.d.ts",
15
- "import": "./dist/index.esm.js",
16
- "require": "./dist/index.cjs.js"
15
+ "import": "./dist/index.js"
17
16
  },
18
17
  "./*": "./*"
19
18
  },
20
19
  "scripts": {
21
- "build": "rollup -c rollup.config.js"
20
+ "build": "tsdown -c tsdown.config.ts"
22
21
  },
23
22
  "devDependencies": {
24
- "@rollup/plugin-commonjs": "^29.0.2",
25
- "@rollup/plugin-json": "^6.1.0",
26
- "@rollup/plugin-node-resolve": "^16.0.3",
27
- "@rollup/plugin-replace": "^6.0.3",
28
- "@rollup/plugin-terser": "^1.0.0",
29
- "@rollup/plugin-typescript": "^12.3.0",
30
- "@types/node": "^24.12.0",
31
- "rollup": "^4.59.0",
32
- "rollup-plugin-dts": "^6.4.0",
23
+ "@types/node": "^26.1.1",
24
+ "@vitejs/plugin-vue": "^6.0.8",
25
+ "@vitejs/plugin-vue-jsx": "^5.1.6",
26
+ "@vue/test-utils": "^2.4.11",
27
+ "oxfmt": "^0.59.0",
28
+ "oxlint": "^1.74.0",
29
+ "oxlint-tsgolint": "^0.24.0",
30
+ "tsdown": "^0.22.8",
33
31
  "tslib": "^2.8.1",
34
- "typescript": "^5.9.3",
35
- "typescript-eslint-standard": "^10.0.1",
36
- "vitest": "^4.1.0"
32
+ "typescript": "^7.0.2",
33
+ "vitest": "^4.1.10"
37
34
  },
38
35
  "author": "mivui",
39
36
  "license": "MIT",
package/dist/index.cjs.js DELETED
@@ -1,378 +0,0 @@
1
- 'use strict';
2
-
3
- //AudioWorkletProcessor 单声道 音频采样率是16000 使用pcm 1.9kb发送一次数据
4
- function audioProcessorJs() {
5
- const code = `
6
- class AudioProcessor extends AudioWorkletProcessor {
7
- constructor({ processorOptions }) {
8
- super();
9
- this.pcmBuffer = new Int16Array(0);
10
- this.chunkSize = processorOptions.chunkSize;
11
- this.port.onmessage = (event) => {
12
- const { data } = event;
13
- if (data === 'pause') {
14
- this.isPaused = true;
15
- } else if (data === 'resume') {
16
- this.isPaused = false;
17
- } else if (data === 'stop') {
18
- this.isPaused = true;
19
- }
20
- };
21
- }
22
-
23
- process(inputs, outputs, parameters) {
24
- if (this.isPaused) {
25
- return true;
26
- }
27
- const input = inputs[0]; // 单声道输入
28
- const buffer = input[0]; // Float32Array
29
- const length = buffer.length;
30
-
31
- // 转换为 16 位 PCM
32
- const pcm = new Int16Array(length);
33
- for (let i = 0; i < length; i++) {
34
- pcm[i] = Math.min(1, buffer[i]) * 32767; // 量化到 Int16
35
- }
36
-
37
- // 累积数据
38
- const newLength = this.pcmBuffer.length + length;
39
- const newBuffer = new Int16Array(newLength);
40
- newBuffer.set(this.pcmBuffer);
41
- newBuffer.set(pcm, this.pcmBuffer.length);
42
- this.pcmBuffer = newBuffer;
43
-
44
- // 检查是否达到目标大小
45
- if (this.pcmBuffer.byteLength >= this.chunkSize) {
46
- // 提取指定之前的数据
47
- const dataToSent = this.pcmBuffer.slice(0, this.chunkSize / 2); // 1900 字节 = 950 个 Int16
48
- this.port.postMessage({ e: 'data', data: dataToSent });
49
- // 保留剩余数据
50
- this.pcmBuffer = this.pcmBuffer.slice(this.chunkSize / 2);
51
- }
52
-
53
- return true;
54
- }
55
- }
56
-
57
- registerProcessor('audio-processor', AudioProcessor);
58
- `;
59
- const blob = new Blob([code], { type: 'application/javascript' });
60
- return URL.createObjectURL(blob);
61
- }
62
- /** 场景化预设配置表 */
63
- const SCENE_PRESETS = {
64
- 'quiet-studio': {
65
- noiseCoefficient: 1.6,
66
- voiceRatioThreshold: 0.4,
67
- noiseFrameCount: 3,
68
- frameDurationMs: 20,
69
- },
70
- 'normal-indoor': {
71
- noiseCoefficient: 2.3,
72
- voiceRatioThreshold: 0.5,
73
- noiseFrameCount: 5,
74
- frameDurationMs: 20,
75
- },
76
- 'high-noise': {
77
- noiseCoefficient: 3.8,
78
- voiceRatioThreshold: 0.6,
79
- noiseFrameCount: 8,
80
- frameDurationMs: 15, // 更短的帧提升实时性
81
- },
82
- 'extreme-noise': {
83
- noiseCoefficient: 4.5,
84
- voiceRatioThreshold: 0.7,
85
- noiseFrameCount: 10,
86
- frameDurationMs: 10, // 极短帧快速响应噪声变化
87
- },
88
- 'voice-assistant': {
89
- noiseCoefficient: 2.0,
90
- voiceRatioThreshold: 0.3, // 低占比触发,提升唤醒灵敏度
91
- noiseFrameCount: 2,
92
- frameDurationMs: 20,
93
- },
94
- 'live-stream': {
95
- noiseCoefficient: 2.8,
96
- voiceRatioThreshold: 0.45,
97
- noiseFrameCount: 6,
98
- frameDurationMs: 20,
99
- },
100
- };
101
- class Recorder {
102
- constructor(options) {
103
- Object.defineProperty(this, "_stream", {
104
- enumerable: true,
105
- configurable: true,
106
- writable: true,
107
- value: void 0
108
- });
109
- Object.defineProperty(this, "_audioContext", {
110
- enumerable: true,
111
- configurable: true,
112
- writable: true,
113
- value: void 0
114
- });
115
- Object.defineProperty(this, "_source", {
116
- enumerable: true,
117
- configurable: true,
118
- writable: true,
119
- value: void 0
120
- });
121
- Object.defineProperty(this, "_workletNode", {
122
- enumerable: true,
123
- configurable: true,
124
- writable: true,
125
- value: void 0
126
- });
127
- Object.defineProperty(this, "_pcmData", {
128
- enumerable: true,
129
- configurable: true,
130
- writable: true,
131
- value: void 0
132
- });
133
- Object.defineProperty(this, "_options", {
134
- enumerable: true,
135
- configurable: true,
136
- writable: true,
137
- value: void 0
138
- });
139
- Object.defineProperty(this, "ondataavailable", {
140
- enumerable: true,
141
- configurable: true,
142
- writable: true,
143
- value: void 0
144
- });
145
- Object.defineProperty(this, "onpause", {
146
- enumerable: true,
147
- configurable: true,
148
- writable: true,
149
- value: void 0
150
- });
151
- Object.defineProperty(this, "onresume", {
152
- enumerable: true,
153
- configurable: true,
154
- writable: true,
155
- value: void 0
156
- });
157
- Object.defineProperty(this, "onstart", {
158
- enumerable: true,
159
- configurable: true,
160
- writable: true,
161
- value: void 0
162
- });
163
- Object.defineProperty(this, "onstop", {
164
- enumerable: true,
165
- configurable: true,
166
- writable: true,
167
- value: void 0
168
- });
169
- this._options = options;
170
- this._pcmData = [];
171
- }
172
- async start() {
173
- const { sampleRate, chunkSize, vad } = this._options;
174
- this._stream = await navigator.mediaDevices.getUserMedia({
175
- audio: true,
176
- });
177
- this._audioContext = new AudioContext({ sampleRate });
178
- this._source = this._audioContext.createMediaStreamSource(this._stream);
179
- await this._audioContext.audioWorklet.addModule(audioProcessorJs(), { credentials: 'include' });
180
- this._workletNode = new AudioWorkletNode(this._audioContext, 'audio-processor', {
181
- channelCount: 1, //单声道
182
- channelCountMode: 'explicit', //输出通道数由 channelCount 显式指定
183
- channelInterpretation: 'discrete', //通道是独立的音频流,没有特定方向(适合单声道处理)
184
- processorOptions: { chunkSize },
185
- });
186
- this._source.connect(this._workletNode);
187
- this._workletNode.connect(this._audioContext.destination);
188
- this._workletNode.port.onmessage = (event) => {
189
- const { e, data } = event.data;
190
- if (e === 'data') {
191
- if (vad) {
192
- if (Recorder.isSpeak(data, sampleRate, vad)) {
193
- this._pcmData.push(data);
194
- this.ondataavailable?.(data);
195
- }
196
- }
197
- else {
198
- this._pcmData.push(data);
199
- this.ondataavailable?.(data);
200
- }
201
- }
202
- };
203
- this.onstart?.();
204
- }
205
- pause() {
206
- this._workletNode?.port.postMessage('pause');
207
- this.onpause?.();
208
- }
209
- resume() {
210
- this._workletNode?.port.postMessage('resume');
211
- this.onresume?.();
212
- }
213
- stop() {
214
- this._workletNode?.port.postMessage('stop');
215
- this._stream?.getTracks().forEach((track) => {
216
- track.stop();
217
- });
218
- this._source?.disconnect();
219
- this._workletNode?.disconnect();
220
- this._stream = undefined;
221
- this._source = undefined;
222
- this._workletNode = undefined;
223
- this._audioContext = undefined;
224
- this.onstop?.(Recorder.pcmMerge(this._pcmData));
225
- this._pcmData = [];
226
- }
227
- static pcmMerge(arrays) {
228
- // 计算总长度
229
- const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
230
- // 创建合并后的 Int16Array
231
- const merged = new Int16Array(totalLength);
232
- let offset = 0;
233
- for (const arr of arrays) {
234
- // 将当前数组复制到目标数组的 offset 位置
235
- merged.set(arr, offset);
236
- offset += arr.length;
237
- }
238
- return merged;
239
- }
240
- static pcmToWav(data, sampleRate) {
241
- function writeString(view, offset, type) {
242
- for (let i = 0; i < type.length; i++) {
243
- view.setUint8(offset + i, type.charCodeAt(i));
244
- }
245
- }
246
- const numChannels = 1; // 单声道
247
- const bytesPerSample = 2; // 16 位
248
- const dataLength = data.length;
249
- // 计算总文件大小
250
- const fileSize = 44 + dataLength * bytesPerSample;
251
- // 创建 ArrayBuffer
252
- const buffer = new ArrayBuffer(fileSize);
253
- const view = new DataView(buffer);
254
- // 写入 RIFF 头
255
- writeString(view, 0, 'RIFF');
256
- view.setUint32(4, fileSize - 8, true); // 文件总长度 - 8
257
- writeString(view, 8, 'WAVE');
258
- // 写入 fmt 子块
259
- writeString(view, 12, 'fmt ');
260
- view.setUint32(16, 16, true); // 子块大小
261
- view.setUint16(20, 1, true); // 音频格式 (PCM)
262
- view.setUint16(22, numChannels, true); // 通道数
263
- view.setUint32(24, sampleRate, true); // 采样率
264
- view.setUint32(28, sampleRate * numChannels * bytesPerSample, true); // 字节率
265
- view.setUint16(32, numChannels * bytesPerSample, true); // 块对齐
266
- view.setUint16(34, bytesPerSample * 8, true); // 位深度 (16 位)
267
- // 写入 data 子块
268
- writeString(view, 36, 'data');
269
- view.setUint32(40, dataLength * bytesPerSample, true); // 数据大小
270
- // 写入 PCM 数据
271
- for (let i = 0; i < dataLength; i++) {
272
- view.setInt16(44 + i * bytesPerSample, data[i], true); // 小端序
273
- }
274
- const wavBlob = new Blob([buffer], { type: 'audio/wav' });
275
- return wavBlob;
276
- }
277
- /**
278
- * 语音活动检测(VAD):判断音频片段是否包含说话内容
279
- * @param int16Array 原始PCM音频数据(16位整型)
280
- * @param sampleRate 采样率(如16000、44100)
281
- * @param vad 场景类型或自定义配置
282
- * @returns 是否检测到语音
283
- */
284
- static isSpeak(int16Array, sampleRate, vad) {
285
- // 解析配置:场景预设优先,自定义配置可覆盖
286
- let options;
287
- if (typeof vad === 'string') {
288
- options = { ...SCENE_PRESETS[vad] };
289
- }
290
- else {
291
- // 默认使用普通室内场景配置,再合并自定义选项
292
- options = { ...SCENE_PRESETS['normal-indoor'], ...vad };
293
- }
294
- // 计算帧长度(处理自定义帧时长)
295
- const frameSize = Math.floor(((options.frameDurationMs ?? 20) * sampleRate) / 1000);
296
- const totalFrameCount = Math.ceil(int16Array.length / frameSize);
297
- let speakingFrameCount = 0;
298
- let noiseRmsSum = 0;
299
- // --------------------------
300
- // 1. 计算背景噪声基准RMS
301
- // --------------------------
302
- const actualNoiseFrameCount = Math.min(options.noiseFrameCount, totalFrameCount);
303
- for (let i = 0; i < actualNoiseFrameCount; i++) {
304
- const start = i * frameSize;
305
- const end = Math.min(start + frameSize, int16Array.length);
306
- const frame = int16Array.subarray(start, end);
307
- let sum = 0;
308
- for (let j = 0; j < frame.length; j++) {
309
- const sample = frame[j] / 32768; // 归一化到[-1, 1]
310
- sum += sample * sample;
311
- }
312
- noiseRmsSum += Math.sqrt(sum / frame.length);
313
- }
314
- const baseNoiseRms = noiseRmsSum / actualNoiseFrameCount;
315
- const dynamicThreshold = baseNoiseRms * options.noiseCoefficient;
316
- // --------------------------
317
- // 2. 逐帧检测语音
318
- // --------------------------
319
- for (let i = 0; i < totalFrameCount; i++) {
320
- const start = i * frameSize;
321
- const end = Math.min(start + frameSize, int16Array.length);
322
- const frame = int16Array.subarray(start, end);
323
- let sum = 0;
324
- for (let j = 0; j < frame.length; j++) {
325
- const sample = frame[j] / 32768;
326
- sum += sample * sample;
327
- }
328
- const frameRms = Math.sqrt(sum / frame.length);
329
- if (frameRms > dynamicThreshold) {
330
- speakingFrameCount++;
331
- }
332
- }
333
- // 3. 判断是否超过语音占比阈值
334
- return speakingFrameCount / totalFrameCount > options.voiceRatioThreshold;
335
- }
336
- static parseWav(buffer) {
337
- // 将32位无符号整数转换为4字节字符串
338
- function uint32ToChars(uint32) {
339
- return String.fromCharCode((uint32 >> 24) & 0xff, (uint32 >> 16) & 0xff, (uint32 >> 8) & 0xff, (uint32 >> 0) & 0xff);
340
- }
341
- const dataView = new DataView(buffer);
342
- let offset = 0;
343
- // 1. 验证RIFF头
344
- const riff = dataView.getUint32(offset, false); // 大端序
345
- if (riff !== 0x52494646) {
346
- // 'RIFF' in hex
347
- throw new Error('Not a RIFF file');
348
- }
349
- offset += 4;
350
- const fileSize = dataView.getUint32(offset, true); // 小端序
351
- offset += 4;
352
- const wave = dataView.getUint32(offset, false); // 大端序
353
- if (wave !== 0x57415645) {
354
- // 'WAVE' in hex
355
- throw new Error('Not a WAVE file');
356
- }
357
- offset += 4;
358
- // 2. 遍历块,查找'fmt '块
359
- while (offset < buffer.byteLength) {
360
- const chunkId = dataView.getUint32(offset, false); // 大端序
361
- const chunkSize = dataView.getUint32(offset + 4, true); // 小端序
362
- const chunkIdStr = uint32ToChars(chunkId);
363
- if (chunkIdStr === 'fmt ') {
364
- // 3. 解析'fmt '块数据
365
- const audioFormat = dataView.getUint16(offset + 8, true); // 小端序
366
- const numChannels = dataView.getUint16(offset + 10, true);
367
- const sampleRate = dataView.getUint32(offset + 12, true);
368
- const byteRate = dataView.getUint32(offset + 16, true);
369
- return { numChannels, sampleRate, byteRate, fileSize, audioFormat };
370
- }
371
- // 移动到下一个块
372
- offset += 8 + chunkSize;
373
- }
374
- throw new Error('fmt chunk not found');
375
- }
376
- }
377
-
378
- exports.Recorder = Recorder;
package/dist/index.esm.js DELETED
@@ -1,376 +0,0 @@
1
- //AudioWorkletProcessor 单声道 音频采样率是16000 使用pcm 1.9kb发送一次数据
2
- function audioProcessorJs() {
3
- const code = `
4
- class AudioProcessor extends AudioWorkletProcessor {
5
- constructor({ processorOptions }) {
6
- super();
7
- this.pcmBuffer = new Int16Array(0);
8
- this.chunkSize = processorOptions.chunkSize;
9
- this.port.onmessage = (event) => {
10
- const { data } = event;
11
- if (data === 'pause') {
12
- this.isPaused = true;
13
- } else if (data === 'resume') {
14
- this.isPaused = false;
15
- } else if (data === 'stop') {
16
- this.isPaused = true;
17
- }
18
- };
19
- }
20
-
21
- process(inputs, outputs, parameters) {
22
- if (this.isPaused) {
23
- return true;
24
- }
25
- const input = inputs[0]; // 单声道输入
26
- const buffer = input[0]; // Float32Array
27
- const length = buffer.length;
28
-
29
- // 转换为 16 位 PCM
30
- const pcm = new Int16Array(length);
31
- for (let i = 0; i < length; i++) {
32
- pcm[i] = Math.min(1, buffer[i]) * 32767; // 量化到 Int16
33
- }
34
-
35
- // 累积数据
36
- const newLength = this.pcmBuffer.length + length;
37
- const newBuffer = new Int16Array(newLength);
38
- newBuffer.set(this.pcmBuffer);
39
- newBuffer.set(pcm, this.pcmBuffer.length);
40
- this.pcmBuffer = newBuffer;
41
-
42
- // 检查是否达到目标大小
43
- if (this.pcmBuffer.byteLength >= this.chunkSize) {
44
- // 提取指定之前的数据
45
- const dataToSent = this.pcmBuffer.slice(0, this.chunkSize / 2); // 1900 字节 = 950 个 Int16
46
- this.port.postMessage({ e: 'data', data: dataToSent });
47
- // 保留剩余数据
48
- this.pcmBuffer = this.pcmBuffer.slice(this.chunkSize / 2);
49
- }
50
-
51
- return true;
52
- }
53
- }
54
-
55
- registerProcessor('audio-processor', AudioProcessor);
56
- `;
57
- const blob = new Blob([code], { type: 'application/javascript' });
58
- return URL.createObjectURL(blob);
59
- }
60
- /** 场景化预设配置表 */
61
- const SCENE_PRESETS = {
62
- 'quiet-studio': {
63
- noiseCoefficient: 1.6,
64
- voiceRatioThreshold: 0.4,
65
- noiseFrameCount: 3,
66
- frameDurationMs: 20,
67
- },
68
- 'normal-indoor': {
69
- noiseCoefficient: 2.3,
70
- voiceRatioThreshold: 0.5,
71
- noiseFrameCount: 5,
72
- frameDurationMs: 20,
73
- },
74
- 'high-noise': {
75
- noiseCoefficient: 3.8,
76
- voiceRatioThreshold: 0.6,
77
- noiseFrameCount: 8,
78
- frameDurationMs: 15, // 更短的帧提升实时性
79
- },
80
- 'extreme-noise': {
81
- noiseCoefficient: 4.5,
82
- voiceRatioThreshold: 0.7,
83
- noiseFrameCount: 10,
84
- frameDurationMs: 10, // 极短帧快速响应噪声变化
85
- },
86
- 'voice-assistant': {
87
- noiseCoefficient: 2.0,
88
- voiceRatioThreshold: 0.3, // 低占比触发,提升唤醒灵敏度
89
- noiseFrameCount: 2,
90
- frameDurationMs: 20,
91
- },
92
- 'live-stream': {
93
- noiseCoefficient: 2.8,
94
- voiceRatioThreshold: 0.45,
95
- noiseFrameCount: 6,
96
- frameDurationMs: 20,
97
- },
98
- };
99
- class Recorder {
100
- constructor(options) {
101
- Object.defineProperty(this, "_stream", {
102
- enumerable: true,
103
- configurable: true,
104
- writable: true,
105
- value: void 0
106
- });
107
- Object.defineProperty(this, "_audioContext", {
108
- enumerable: true,
109
- configurable: true,
110
- writable: true,
111
- value: void 0
112
- });
113
- Object.defineProperty(this, "_source", {
114
- enumerable: true,
115
- configurable: true,
116
- writable: true,
117
- value: void 0
118
- });
119
- Object.defineProperty(this, "_workletNode", {
120
- enumerable: true,
121
- configurable: true,
122
- writable: true,
123
- value: void 0
124
- });
125
- Object.defineProperty(this, "_pcmData", {
126
- enumerable: true,
127
- configurable: true,
128
- writable: true,
129
- value: void 0
130
- });
131
- Object.defineProperty(this, "_options", {
132
- enumerable: true,
133
- configurable: true,
134
- writable: true,
135
- value: void 0
136
- });
137
- Object.defineProperty(this, "ondataavailable", {
138
- enumerable: true,
139
- configurable: true,
140
- writable: true,
141
- value: void 0
142
- });
143
- Object.defineProperty(this, "onpause", {
144
- enumerable: true,
145
- configurable: true,
146
- writable: true,
147
- value: void 0
148
- });
149
- Object.defineProperty(this, "onresume", {
150
- enumerable: true,
151
- configurable: true,
152
- writable: true,
153
- value: void 0
154
- });
155
- Object.defineProperty(this, "onstart", {
156
- enumerable: true,
157
- configurable: true,
158
- writable: true,
159
- value: void 0
160
- });
161
- Object.defineProperty(this, "onstop", {
162
- enumerable: true,
163
- configurable: true,
164
- writable: true,
165
- value: void 0
166
- });
167
- this._options = options;
168
- this._pcmData = [];
169
- }
170
- async start() {
171
- const { sampleRate, chunkSize, vad } = this._options;
172
- this._stream = await navigator.mediaDevices.getUserMedia({
173
- audio: true,
174
- });
175
- this._audioContext = new AudioContext({ sampleRate });
176
- this._source = this._audioContext.createMediaStreamSource(this._stream);
177
- await this._audioContext.audioWorklet.addModule(audioProcessorJs(), { credentials: 'include' });
178
- this._workletNode = new AudioWorkletNode(this._audioContext, 'audio-processor', {
179
- channelCount: 1, //单声道
180
- channelCountMode: 'explicit', //输出通道数由 channelCount 显式指定
181
- channelInterpretation: 'discrete', //通道是独立的音频流,没有特定方向(适合单声道处理)
182
- processorOptions: { chunkSize },
183
- });
184
- this._source.connect(this._workletNode);
185
- this._workletNode.connect(this._audioContext.destination);
186
- this._workletNode.port.onmessage = (event) => {
187
- const { e, data } = event.data;
188
- if (e === 'data') {
189
- if (vad) {
190
- if (Recorder.isSpeak(data, sampleRate, vad)) {
191
- this._pcmData.push(data);
192
- this.ondataavailable?.(data);
193
- }
194
- }
195
- else {
196
- this._pcmData.push(data);
197
- this.ondataavailable?.(data);
198
- }
199
- }
200
- };
201
- this.onstart?.();
202
- }
203
- pause() {
204
- this._workletNode?.port.postMessage('pause');
205
- this.onpause?.();
206
- }
207
- resume() {
208
- this._workletNode?.port.postMessage('resume');
209
- this.onresume?.();
210
- }
211
- stop() {
212
- this._workletNode?.port.postMessage('stop');
213
- this._stream?.getTracks().forEach((track) => {
214
- track.stop();
215
- });
216
- this._source?.disconnect();
217
- this._workletNode?.disconnect();
218
- this._stream = undefined;
219
- this._source = undefined;
220
- this._workletNode = undefined;
221
- this._audioContext = undefined;
222
- this.onstop?.(Recorder.pcmMerge(this._pcmData));
223
- this._pcmData = [];
224
- }
225
- static pcmMerge(arrays) {
226
- // 计算总长度
227
- const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
228
- // 创建合并后的 Int16Array
229
- const merged = new Int16Array(totalLength);
230
- let offset = 0;
231
- for (const arr of arrays) {
232
- // 将当前数组复制到目标数组的 offset 位置
233
- merged.set(arr, offset);
234
- offset += arr.length;
235
- }
236
- return merged;
237
- }
238
- static pcmToWav(data, sampleRate) {
239
- function writeString(view, offset, type) {
240
- for (let i = 0; i < type.length; i++) {
241
- view.setUint8(offset + i, type.charCodeAt(i));
242
- }
243
- }
244
- const numChannels = 1; // 单声道
245
- const bytesPerSample = 2; // 16 位
246
- const dataLength = data.length;
247
- // 计算总文件大小
248
- const fileSize = 44 + dataLength * bytesPerSample;
249
- // 创建 ArrayBuffer
250
- const buffer = new ArrayBuffer(fileSize);
251
- const view = new DataView(buffer);
252
- // 写入 RIFF 头
253
- writeString(view, 0, 'RIFF');
254
- view.setUint32(4, fileSize - 8, true); // 文件总长度 - 8
255
- writeString(view, 8, 'WAVE');
256
- // 写入 fmt 子块
257
- writeString(view, 12, 'fmt ');
258
- view.setUint32(16, 16, true); // 子块大小
259
- view.setUint16(20, 1, true); // 音频格式 (PCM)
260
- view.setUint16(22, numChannels, true); // 通道数
261
- view.setUint32(24, sampleRate, true); // 采样率
262
- view.setUint32(28, sampleRate * numChannels * bytesPerSample, true); // 字节率
263
- view.setUint16(32, numChannels * bytesPerSample, true); // 块对齐
264
- view.setUint16(34, bytesPerSample * 8, true); // 位深度 (16 位)
265
- // 写入 data 子块
266
- writeString(view, 36, 'data');
267
- view.setUint32(40, dataLength * bytesPerSample, true); // 数据大小
268
- // 写入 PCM 数据
269
- for (let i = 0; i < dataLength; i++) {
270
- view.setInt16(44 + i * bytesPerSample, data[i], true); // 小端序
271
- }
272
- const wavBlob = new Blob([buffer], { type: 'audio/wav' });
273
- return wavBlob;
274
- }
275
- /**
276
- * 语音活动检测(VAD):判断音频片段是否包含说话内容
277
- * @param int16Array 原始PCM音频数据(16位整型)
278
- * @param sampleRate 采样率(如16000、44100)
279
- * @param vad 场景类型或自定义配置
280
- * @returns 是否检测到语音
281
- */
282
- static isSpeak(int16Array, sampleRate, vad) {
283
- // 解析配置:场景预设优先,自定义配置可覆盖
284
- let options;
285
- if (typeof vad === 'string') {
286
- options = { ...SCENE_PRESETS[vad] };
287
- }
288
- else {
289
- // 默认使用普通室内场景配置,再合并自定义选项
290
- options = { ...SCENE_PRESETS['normal-indoor'], ...vad };
291
- }
292
- // 计算帧长度(处理自定义帧时长)
293
- const frameSize = Math.floor(((options.frameDurationMs ?? 20) * sampleRate) / 1000);
294
- const totalFrameCount = Math.ceil(int16Array.length / frameSize);
295
- let speakingFrameCount = 0;
296
- let noiseRmsSum = 0;
297
- // --------------------------
298
- // 1. 计算背景噪声基准RMS
299
- // --------------------------
300
- const actualNoiseFrameCount = Math.min(options.noiseFrameCount, totalFrameCount);
301
- for (let i = 0; i < actualNoiseFrameCount; i++) {
302
- const start = i * frameSize;
303
- const end = Math.min(start + frameSize, int16Array.length);
304
- const frame = int16Array.subarray(start, end);
305
- let sum = 0;
306
- for (let j = 0; j < frame.length; j++) {
307
- const sample = frame[j] / 32768; // 归一化到[-1, 1]
308
- sum += sample * sample;
309
- }
310
- noiseRmsSum += Math.sqrt(sum / frame.length);
311
- }
312
- const baseNoiseRms = noiseRmsSum / actualNoiseFrameCount;
313
- const dynamicThreshold = baseNoiseRms * options.noiseCoefficient;
314
- // --------------------------
315
- // 2. 逐帧检测语音
316
- // --------------------------
317
- for (let i = 0; i < totalFrameCount; i++) {
318
- const start = i * frameSize;
319
- const end = Math.min(start + frameSize, int16Array.length);
320
- const frame = int16Array.subarray(start, end);
321
- let sum = 0;
322
- for (let j = 0; j < frame.length; j++) {
323
- const sample = frame[j] / 32768;
324
- sum += sample * sample;
325
- }
326
- const frameRms = Math.sqrt(sum / frame.length);
327
- if (frameRms > dynamicThreshold) {
328
- speakingFrameCount++;
329
- }
330
- }
331
- // 3. 判断是否超过语音占比阈值
332
- return speakingFrameCount / totalFrameCount > options.voiceRatioThreshold;
333
- }
334
- static parseWav(buffer) {
335
- // 将32位无符号整数转换为4字节字符串
336
- function uint32ToChars(uint32) {
337
- return String.fromCharCode((uint32 >> 24) & 0xff, (uint32 >> 16) & 0xff, (uint32 >> 8) & 0xff, (uint32 >> 0) & 0xff);
338
- }
339
- const dataView = new DataView(buffer);
340
- let offset = 0;
341
- // 1. 验证RIFF头
342
- const riff = dataView.getUint32(offset, false); // 大端序
343
- if (riff !== 0x52494646) {
344
- // 'RIFF' in hex
345
- throw new Error('Not a RIFF file');
346
- }
347
- offset += 4;
348
- const fileSize = dataView.getUint32(offset, true); // 小端序
349
- offset += 4;
350
- const wave = dataView.getUint32(offset, false); // 大端序
351
- if (wave !== 0x57415645) {
352
- // 'WAVE' in hex
353
- throw new Error('Not a WAVE file');
354
- }
355
- offset += 4;
356
- // 2. 遍历块,查找'fmt '块
357
- while (offset < buffer.byteLength) {
358
- const chunkId = dataView.getUint32(offset, false); // 大端序
359
- const chunkSize = dataView.getUint32(offset + 4, true); // 小端序
360
- const chunkIdStr = uint32ToChars(chunkId);
361
- if (chunkIdStr === 'fmt ') {
362
- // 3. 解析'fmt '块数据
363
- const audioFormat = dataView.getUint16(offset + 8, true); // 小端序
364
- const numChannels = dataView.getUint16(offset + 10, true);
365
- const sampleRate = dataView.getUint32(offset + 12, true);
366
- const byteRate = dataView.getUint32(offset + 16, true);
367
- return { numChannels, sampleRate, byteRate, fileSize, audioFormat };
368
- }
369
- // 移动到下一个块
370
- offset += 8 + chunkSize;
371
- }
372
- throw new Error('fmt chunk not found');
373
- }
374
- }
375
-
376
- export { Recorder };