recorder-client 1.0.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/LICENSE +21 -0
- package/README.md +119 -0
- package/dist/index.cjs.js +306 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.esm.js +304 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 mivui
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# recorder-client
|
|
2
|
+
|
|
3
|
+
### audio recording client
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/recorder-client)
|
|
6
|
+
[](https://npmcharts.com/compare/recorder-client?minimal=true)
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
### install
|
|
10
|
+
|
|
11
|
+
```shell
|
|
12
|
+
npm i recorder-client
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### API
|
|
16
|
+
|
|
17
|
+
| property | type | description | default |
|
|
18
|
+
| :-------------: | :-----------------------: | :-------------------------------------------------------------------------: | :-------: |
|
|
19
|
+
| sampleRate | number | audio sampling rate | undefined |
|
|
20
|
+
| chunkSize | number | number of samples in the audio data block (length of the audio buffer zone) | undefined |
|
|
21
|
+
| ignoreMute | boolean | ignore mute or not | undefined |
|
|
22
|
+
| ondataavailable | (pcm: Int16Array) => void | real-time audio data | undefined |
|
|
23
|
+
| onpause | () => void | pause event | undefined |
|
|
24
|
+
| onresume | () => void | resume event | undefined |
|
|
25
|
+
| onstart | () => void | start event | undefined |
|
|
26
|
+
| onstop | (wav: Blob) => void | stop event | undefined |
|
|
27
|
+
| pause | () => void | audio pause | undefined |
|
|
28
|
+
| resume | () => void | audio resume | undefined |
|
|
29
|
+
| start | () => void | audio start | undefined |
|
|
30
|
+
| stop | () => void | audio stop | undefined |
|
|
31
|
+
|
|
32
|
+
### Helper
|
|
33
|
+
|
|
34
|
+
| property | type | description |
|
|
35
|
+
| :------: | :----------------------------------------------: | :-----------------------------------: |
|
|
36
|
+
| pcmMerge | (arrays: Int16Array[])=> Int16Array<ArrayBuffer> | merge multiple pcm streams |
|
|
37
|
+
| pcmToWav | (pcm: Int16Array, sampleRate: number)=> Blob | convert PCM to WAV |
|
|
38
|
+
| parseWav | (buffer: ArrayBuffer)=> object | obtain information about the wav file |
|
|
39
|
+
|
|
40
|
+
### Example
|
|
41
|
+
|
|
42
|
+
> simple example
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { Recorder } from 'recorder-client';
|
|
46
|
+
|
|
47
|
+
const recorder = new Recorder({
|
|
48
|
+
sampleRate: 16000,
|
|
49
|
+
chunkSize: 1900,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
recorder.onstart = () => {};
|
|
53
|
+
recorder.onpause = () => {};
|
|
54
|
+
recorder.onresume = () => {};
|
|
55
|
+
recorder.onstop = (blob) => {};
|
|
56
|
+
recorder.ondataavailable = (pcm) => {};
|
|
57
|
+
|
|
58
|
+
const onClick = () => {
|
|
59
|
+
recorder.pause();
|
|
60
|
+
recorder.resume();
|
|
61
|
+
recorder.start();
|
|
62
|
+
recorder.stop();
|
|
63
|
+
};
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
> pcmMerge
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { Recorder } from 'recorder-client';
|
|
70
|
+
|
|
71
|
+
const recorder = new Recorder({
|
|
72
|
+
sampleRate: 16000,
|
|
73
|
+
chunkSize: 1900,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const pcms: Int16Array[] = [];
|
|
77
|
+
|
|
78
|
+
recorder.ondataavailable = (pcm) => {
|
|
79
|
+
pcms.push(pcm);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const onMerge = () => {
|
|
83
|
+
return Recorder.pcmMerge(pcms);
|
|
84
|
+
};
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
> pcmToWav
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { Recorder } from 'recorder-client';
|
|
91
|
+
|
|
92
|
+
const recorder = new Recorder({
|
|
93
|
+
sampleRate: 16000,
|
|
94
|
+
chunkSize: 1900,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
recorder.ondataavailable = (pcm) => {
|
|
98
|
+
const blob = pcmToWav(pcm, 16000);
|
|
99
|
+
};
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
> parseWav
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { Recorder } from 'recorder-client';
|
|
106
|
+
|
|
107
|
+
interface Wav {
|
|
108
|
+
numChannels: number;
|
|
109
|
+
sampleRate: number;
|
|
110
|
+
byteRate: number;
|
|
111
|
+
fileSize: number;
|
|
112
|
+
audioFormat: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const getWav = async (file: File) => {
|
|
116
|
+
const buffer = await file.arrayBuffer();
|
|
117
|
+
const wav: Wav = Recorder.parseWav(buffer);
|
|
118
|
+
};
|
|
119
|
+
```
|
|
@@ -0,0 +1,306 @@
|
|
|
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
|
+
class Recorder {
|
|
63
|
+
constructor(options) {
|
|
64
|
+
Object.defineProperty(this, '_stream', {
|
|
65
|
+
enumerable: true,
|
|
66
|
+
configurable: true,
|
|
67
|
+
writable: true,
|
|
68
|
+
value: void 0,
|
|
69
|
+
});
|
|
70
|
+
Object.defineProperty(this, '_audioContext', {
|
|
71
|
+
enumerable: true,
|
|
72
|
+
configurable: true,
|
|
73
|
+
writable: true,
|
|
74
|
+
value: void 0,
|
|
75
|
+
});
|
|
76
|
+
Object.defineProperty(this, '_source', {
|
|
77
|
+
enumerable: true,
|
|
78
|
+
configurable: true,
|
|
79
|
+
writable: true,
|
|
80
|
+
value: void 0,
|
|
81
|
+
});
|
|
82
|
+
Object.defineProperty(this, '_workletNode', {
|
|
83
|
+
enumerable: true,
|
|
84
|
+
configurable: true,
|
|
85
|
+
writable: true,
|
|
86
|
+
value: void 0,
|
|
87
|
+
});
|
|
88
|
+
Object.defineProperty(this, '_pcmData', {
|
|
89
|
+
enumerable: true,
|
|
90
|
+
configurable: true,
|
|
91
|
+
writable: true,
|
|
92
|
+
value: void 0,
|
|
93
|
+
});
|
|
94
|
+
Object.defineProperty(this, '_options', {
|
|
95
|
+
enumerable: true,
|
|
96
|
+
configurable: true,
|
|
97
|
+
writable: true,
|
|
98
|
+
value: void 0,
|
|
99
|
+
});
|
|
100
|
+
Object.defineProperty(this, 'ondataavailable', {
|
|
101
|
+
enumerable: true,
|
|
102
|
+
configurable: true,
|
|
103
|
+
writable: true,
|
|
104
|
+
value: void 0,
|
|
105
|
+
});
|
|
106
|
+
Object.defineProperty(this, 'onpause', {
|
|
107
|
+
enumerable: true,
|
|
108
|
+
configurable: true,
|
|
109
|
+
writable: true,
|
|
110
|
+
value: void 0,
|
|
111
|
+
});
|
|
112
|
+
Object.defineProperty(this, 'onresume', {
|
|
113
|
+
enumerable: true,
|
|
114
|
+
configurable: true,
|
|
115
|
+
writable: true,
|
|
116
|
+
value: void 0,
|
|
117
|
+
});
|
|
118
|
+
Object.defineProperty(this, 'onstart', {
|
|
119
|
+
enumerable: true,
|
|
120
|
+
configurable: true,
|
|
121
|
+
writable: true,
|
|
122
|
+
value: void 0,
|
|
123
|
+
});
|
|
124
|
+
Object.defineProperty(this, 'onstop', {
|
|
125
|
+
enumerable: true,
|
|
126
|
+
configurable: true,
|
|
127
|
+
writable: true,
|
|
128
|
+
value: void 0,
|
|
129
|
+
});
|
|
130
|
+
this._options = options;
|
|
131
|
+
this._pcmData = [];
|
|
132
|
+
}
|
|
133
|
+
async start() {
|
|
134
|
+
const { sampleRate, chunkSize, ignoreMute } = this._options;
|
|
135
|
+
this._stream = await navigator.mediaDevices.getUserMedia({
|
|
136
|
+
audio: true,
|
|
137
|
+
});
|
|
138
|
+
this._audioContext = new AudioContext({ sampleRate });
|
|
139
|
+
this._source = this._audioContext.createMediaStreamSource(this._stream);
|
|
140
|
+
await this._audioContext.audioWorklet.addModule(audioProcessorJs(), { credentials: 'include' });
|
|
141
|
+
this._workletNode = new AudioWorkletNode(this._audioContext, 'audio-processor', {
|
|
142
|
+
channelCount: 1, //单声道
|
|
143
|
+
channelCountMode: 'explicit', //输出通道数由 channelCount 显式指定
|
|
144
|
+
channelInterpretation: 'discrete', //通道是独立的音频流,没有特定方向(适合单声道处理)
|
|
145
|
+
processorOptions: { chunkSize },
|
|
146
|
+
});
|
|
147
|
+
this._source.connect(this._workletNode);
|
|
148
|
+
this._workletNode.connect(this._audioContext.destination);
|
|
149
|
+
this._workletNode.port.onmessage = (event) => {
|
|
150
|
+
const { e, data } = event.data;
|
|
151
|
+
if (e === 'data') {
|
|
152
|
+
if (ignoreMute) {
|
|
153
|
+
if (Recorder.isSpeaking(data, sampleRate)) {
|
|
154
|
+
this._pcmData.push(data);
|
|
155
|
+
this.ondataavailable?.(data);
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
this._pcmData.push(data);
|
|
159
|
+
this.ondataavailable?.(data);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
this.onstart?.();
|
|
164
|
+
}
|
|
165
|
+
pause() {
|
|
166
|
+
this._workletNode?.port.postMessage('pause');
|
|
167
|
+
this.onpause?.();
|
|
168
|
+
}
|
|
169
|
+
resume() {
|
|
170
|
+
this._workletNode?.port.postMessage('resume');
|
|
171
|
+
this.onresume?.();
|
|
172
|
+
}
|
|
173
|
+
stop() {
|
|
174
|
+
this._workletNode?.port.postMessage('stop');
|
|
175
|
+
this._stream?.getTracks().forEach((track) => {
|
|
176
|
+
track.stop();
|
|
177
|
+
});
|
|
178
|
+
this._source?.disconnect();
|
|
179
|
+
this._workletNode?.disconnect();
|
|
180
|
+
this._stream = undefined;
|
|
181
|
+
this._source = undefined;
|
|
182
|
+
this._workletNode = undefined;
|
|
183
|
+
this._audioContext = undefined;
|
|
184
|
+
this.onstop?.(Recorder.pcmToWav(Recorder.pcmMerge(this._pcmData), this._options.sampleRate));
|
|
185
|
+
this._pcmData = [];
|
|
186
|
+
}
|
|
187
|
+
static pcmMerge(arrays) {
|
|
188
|
+
// 计算总长度
|
|
189
|
+
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
|
|
190
|
+
// 创建合并后的 Int16Array
|
|
191
|
+
const merged = new Int16Array(totalLength);
|
|
192
|
+
let offset = 0;
|
|
193
|
+
for (const arr of arrays) {
|
|
194
|
+
// 将当前数组复制到目标数组的 offset 位置
|
|
195
|
+
merged.set(arr, offset);
|
|
196
|
+
offset += arr.length;
|
|
197
|
+
}
|
|
198
|
+
return merged;
|
|
199
|
+
}
|
|
200
|
+
static pcmToWav(data, sampleRate) {
|
|
201
|
+
function writeString(view, offset, type) {
|
|
202
|
+
for (let i = 0; i < type.length; i++) {
|
|
203
|
+
view.setUint8(offset + i, type.charCodeAt(i));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const numChannels = 1; // 单声道
|
|
207
|
+
const bytesPerSample = 2; // 16 位
|
|
208
|
+
const dataLength = data.length;
|
|
209
|
+
// 计算总文件大小
|
|
210
|
+
const fileSize = 44 + dataLength * bytesPerSample;
|
|
211
|
+
// 创建 ArrayBuffer
|
|
212
|
+
const buffer = new ArrayBuffer(fileSize);
|
|
213
|
+
const view = new DataView(buffer);
|
|
214
|
+
// 写入 RIFF 头
|
|
215
|
+
writeString(view, 0, 'RIFF');
|
|
216
|
+
view.setUint32(4, fileSize - 8, true); // 文件总长度 - 8
|
|
217
|
+
writeString(view, 8, 'WAVE');
|
|
218
|
+
// 写入 fmt 子块
|
|
219
|
+
writeString(view, 12, 'fmt ');
|
|
220
|
+
view.setUint32(16, 16, true); // 子块大小
|
|
221
|
+
view.setUint16(20, 1, true); // 音频格式 (PCM)
|
|
222
|
+
view.setUint16(22, numChannels, true); // 通道数
|
|
223
|
+
view.setUint32(24, sampleRate, true); // 采样率
|
|
224
|
+
view.setUint32(28, sampleRate * numChannels * bytesPerSample, true); // 字节率
|
|
225
|
+
view.setUint16(32, numChannels * bytesPerSample, true); // 块对齐
|
|
226
|
+
view.setUint16(34, bytesPerSample * 8, true); // 位深度 (16 位)
|
|
227
|
+
// 写入 data 子块
|
|
228
|
+
writeString(view, 36, 'data');
|
|
229
|
+
view.setUint32(40, dataLength * bytesPerSample, true); // 数据大小
|
|
230
|
+
// 写入 PCM 数据
|
|
231
|
+
for (let i = 0; i < dataLength; i++) {
|
|
232
|
+
view.setInt16(44 + i * bytesPerSample, data[i], true); // 小端序
|
|
233
|
+
}
|
|
234
|
+
const wavBlob = new Blob([buffer], { type: 'audio/wav' });
|
|
235
|
+
return wavBlob;
|
|
236
|
+
}
|
|
237
|
+
static isSpeaking(int16Array, sampleRate) {
|
|
238
|
+
const frameSize = Math.floor(0.02 * sampleRate); // 20ms 帧长度
|
|
239
|
+
const threshold = 0.01; // 能量阈值(需根据实际调整)
|
|
240
|
+
let speakingFrames = 0;
|
|
241
|
+
const frameCount = Math.ceil(int16Array.length / frameSize);
|
|
242
|
+
for (let i = 0; i < frameCount; i++) {
|
|
243
|
+
const start = i * frameSize;
|
|
244
|
+
const end = Math.min(start + frameSize, int16Array.length);
|
|
245
|
+
const frame = int16Array.slice(start, end);
|
|
246
|
+
let sum = 0;
|
|
247
|
+
for (let j = 0; j < frame.length; j++) {
|
|
248
|
+
const sample = frame[j] / 32768; // 归一化到 [-1, 1]
|
|
249
|
+
sum += sample * sample;
|
|
250
|
+
}
|
|
251
|
+
const rms = Math.sqrt(sum / frame.length);
|
|
252
|
+
if (rms > threshold) {
|
|
253
|
+
speakingFrames++;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// 如果超过 50% 的帧有语音,认为在说话
|
|
257
|
+
return speakingFrames / frameCount > 0.5;
|
|
258
|
+
}
|
|
259
|
+
static parseWav(buffer) {
|
|
260
|
+
// 将32位无符号整数转换为4字节字符串
|
|
261
|
+
function uint32ToChars(uint32) {
|
|
262
|
+
return String.fromCharCode(
|
|
263
|
+
(uint32 >> 24) & 0xff,
|
|
264
|
+
(uint32 >> 16) & 0xff,
|
|
265
|
+
(uint32 >> 8) & 0xff,
|
|
266
|
+
(uint32 >> 0) & 0xff,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
const dataView = new DataView(buffer);
|
|
270
|
+
let offset = 0;
|
|
271
|
+
// 1. 验证RIFF头
|
|
272
|
+
const riff = dataView.getUint32(offset, false); // 大端序
|
|
273
|
+
if (riff !== 0x52494646) {
|
|
274
|
+
// 'RIFF' in hex
|
|
275
|
+
throw new Error('Not a RIFF file');
|
|
276
|
+
}
|
|
277
|
+
offset += 4;
|
|
278
|
+
const fileSize = dataView.getUint32(offset, true); // 小端序
|
|
279
|
+
offset += 4;
|
|
280
|
+
const wave = dataView.getUint32(offset, false); // 大端序
|
|
281
|
+
if (wave !== 0x57415645) {
|
|
282
|
+
// 'WAVE' in hex
|
|
283
|
+
throw new Error('Not a WAVE file');
|
|
284
|
+
}
|
|
285
|
+
offset += 4;
|
|
286
|
+
// 2. 遍历块,查找'fmt '块
|
|
287
|
+
while (offset < buffer.byteLength) {
|
|
288
|
+
const chunkId = dataView.getUint32(offset, false); // 大端序
|
|
289
|
+
const chunkSize = dataView.getUint32(offset + 4, true); // 小端序
|
|
290
|
+
const chunkIdStr = uint32ToChars(chunkId);
|
|
291
|
+
if (chunkIdStr === 'fmt ') {
|
|
292
|
+
// 3. 解析'fmt '块数据
|
|
293
|
+
const audioFormat = dataView.getUint16(offset + 8, true); // 小端序
|
|
294
|
+
const numChannels = dataView.getUint16(offset + 10, true);
|
|
295
|
+
const sampleRate = dataView.getUint32(offset + 12, true);
|
|
296
|
+
const byteRate = dataView.getUint32(offset + 16, true);
|
|
297
|
+
return { numChannels, sampleRate, byteRate, fileSize, audioFormat };
|
|
298
|
+
}
|
|
299
|
+
// 移动到下一个块
|
|
300
|
+
offset += 8 + chunkSize;
|
|
301
|
+
}
|
|
302
|
+
throw new Error('fmt chunk not found');
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
exports.Recorder = Recorder;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
interface RecorderConstructor {
|
|
2
|
+
sampleRate: number;
|
|
3
|
+
chunkSize: number;
|
|
4
|
+
ignoreMute?: boolean;
|
|
5
|
+
}
|
|
6
|
+
declare class Recorder {
|
|
7
|
+
private _stream?;
|
|
8
|
+
private _audioContext?;
|
|
9
|
+
private _source?;
|
|
10
|
+
private _workletNode?;
|
|
11
|
+
private _pcmData;
|
|
12
|
+
private readonly _options;
|
|
13
|
+
ondataavailable?: (data: Int16Array) => void;
|
|
14
|
+
onpause?: () => void;
|
|
15
|
+
onresume?: () => void;
|
|
16
|
+
onstart?: () => void;
|
|
17
|
+
onstop?: (blob: Blob) => void;
|
|
18
|
+
constructor(options: RecorderConstructor);
|
|
19
|
+
start(): Promise<void>;
|
|
20
|
+
pause(): void;
|
|
21
|
+
resume(): void;
|
|
22
|
+
stop(): void;
|
|
23
|
+
static pcmMerge(arrays: Int16Array[]): Int16Array<ArrayBuffer>;
|
|
24
|
+
static pcmToWav(data: Int16Array, sampleRate: number): Blob;
|
|
25
|
+
static isSpeaking(int16Array: Int16Array, sampleRate: number): boolean;
|
|
26
|
+
static parseWav(buffer: ArrayBuffer): {
|
|
27
|
+
numChannels: number;
|
|
28
|
+
sampleRate: number;
|
|
29
|
+
byteRate: number;
|
|
30
|
+
fileSize: number;
|
|
31
|
+
audioFormat: number;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export { Recorder };
|
|
36
|
+
export type { RecorderConstructor };
|
|
@@ -0,0 +1,304 @@
|
|
|
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
|
+
class Recorder {
|
|
61
|
+
constructor(options) {
|
|
62
|
+
Object.defineProperty(this, '_stream', {
|
|
63
|
+
enumerable: true,
|
|
64
|
+
configurable: true,
|
|
65
|
+
writable: true,
|
|
66
|
+
value: void 0,
|
|
67
|
+
});
|
|
68
|
+
Object.defineProperty(this, '_audioContext', {
|
|
69
|
+
enumerable: true,
|
|
70
|
+
configurable: true,
|
|
71
|
+
writable: true,
|
|
72
|
+
value: void 0,
|
|
73
|
+
});
|
|
74
|
+
Object.defineProperty(this, '_source', {
|
|
75
|
+
enumerable: true,
|
|
76
|
+
configurable: true,
|
|
77
|
+
writable: true,
|
|
78
|
+
value: void 0,
|
|
79
|
+
});
|
|
80
|
+
Object.defineProperty(this, '_workletNode', {
|
|
81
|
+
enumerable: true,
|
|
82
|
+
configurable: true,
|
|
83
|
+
writable: true,
|
|
84
|
+
value: void 0,
|
|
85
|
+
});
|
|
86
|
+
Object.defineProperty(this, '_pcmData', {
|
|
87
|
+
enumerable: true,
|
|
88
|
+
configurable: true,
|
|
89
|
+
writable: true,
|
|
90
|
+
value: void 0,
|
|
91
|
+
});
|
|
92
|
+
Object.defineProperty(this, '_options', {
|
|
93
|
+
enumerable: true,
|
|
94
|
+
configurable: true,
|
|
95
|
+
writable: true,
|
|
96
|
+
value: void 0,
|
|
97
|
+
});
|
|
98
|
+
Object.defineProperty(this, 'ondataavailable', {
|
|
99
|
+
enumerable: true,
|
|
100
|
+
configurable: true,
|
|
101
|
+
writable: true,
|
|
102
|
+
value: void 0,
|
|
103
|
+
});
|
|
104
|
+
Object.defineProperty(this, 'onpause', {
|
|
105
|
+
enumerable: true,
|
|
106
|
+
configurable: true,
|
|
107
|
+
writable: true,
|
|
108
|
+
value: void 0,
|
|
109
|
+
});
|
|
110
|
+
Object.defineProperty(this, 'onresume', {
|
|
111
|
+
enumerable: true,
|
|
112
|
+
configurable: true,
|
|
113
|
+
writable: true,
|
|
114
|
+
value: void 0,
|
|
115
|
+
});
|
|
116
|
+
Object.defineProperty(this, 'onstart', {
|
|
117
|
+
enumerable: true,
|
|
118
|
+
configurable: true,
|
|
119
|
+
writable: true,
|
|
120
|
+
value: void 0,
|
|
121
|
+
});
|
|
122
|
+
Object.defineProperty(this, 'onstop', {
|
|
123
|
+
enumerable: true,
|
|
124
|
+
configurable: true,
|
|
125
|
+
writable: true,
|
|
126
|
+
value: void 0,
|
|
127
|
+
});
|
|
128
|
+
this._options = options;
|
|
129
|
+
this._pcmData = [];
|
|
130
|
+
}
|
|
131
|
+
async start() {
|
|
132
|
+
const { sampleRate, chunkSize, ignoreMute } = this._options;
|
|
133
|
+
this._stream = await navigator.mediaDevices.getUserMedia({
|
|
134
|
+
audio: true,
|
|
135
|
+
});
|
|
136
|
+
this._audioContext = new AudioContext({ sampleRate });
|
|
137
|
+
this._source = this._audioContext.createMediaStreamSource(this._stream);
|
|
138
|
+
await this._audioContext.audioWorklet.addModule(audioProcessorJs(), { credentials: 'include' });
|
|
139
|
+
this._workletNode = new AudioWorkletNode(this._audioContext, 'audio-processor', {
|
|
140
|
+
channelCount: 1, //单声道
|
|
141
|
+
channelCountMode: 'explicit', //输出通道数由 channelCount 显式指定
|
|
142
|
+
channelInterpretation: 'discrete', //通道是独立的音频流,没有特定方向(适合单声道处理)
|
|
143
|
+
processorOptions: { chunkSize },
|
|
144
|
+
});
|
|
145
|
+
this._source.connect(this._workletNode);
|
|
146
|
+
this._workletNode.connect(this._audioContext.destination);
|
|
147
|
+
this._workletNode.port.onmessage = (event) => {
|
|
148
|
+
const { e, data } = event.data;
|
|
149
|
+
if (e === 'data') {
|
|
150
|
+
if (ignoreMute) {
|
|
151
|
+
if (Recorder.isSpeaking(data, sampleRate)) {
|
|
152
|
+
this._pcmData.push(data);
|
|
153
|
+
this.ondataavailable?.(data);
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
this._pcmData.push(data);
|
|
157
|
+
this.ondataavailable?.(data);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
this.onstart?.();
|
|
162
|
+
}
|
|
163
|
+
pause() {
|
|
164
|
+
this._workletNode?.port.postMessage('pause');
|
|
165
|
+
this.onpause?.();
|
|
166
|
+
}
|
|
167
|
+
resume() {
|
|
168
|
+
this._workletNode?.port.postMessage('resume');
|
|
169
|
+
this.onresume?.();
|
|
170
|
+
}
|
|
171
|
+
stop() {
|
|
172
|
+
this._workletNode?.port.postMessage('stop');
|
|
173
|
+
this._stream?.getTracks().forEach((track) => {
|
|
174
|
+
track.stop();
|
|
175
|
+
});
|
|
176
|
+
this._source?.disconnect();
|
|
177
|
+
this._workletNode?.disconnect();
|
|
178
|
+
this._stream = undefined;
|
|
179
|
+
this._source = undefined;
|
|
180
|
+
this._workletNode = undefined;
|
|
181
|
+
this._audioContext = undefined;
|
|
182
|
+
this.onstop?.(Recorder.pcmToWav(Recorder.pcmMerge(this._pcmData), this._options.sampleRate));
|
|
183
|
+
this._pcmData = [];
|
|
184
|
+
}
|
|
185
|
+
static pcmMerge(arrays) {
|
|
186
|
+
// 计算总长度
|
|
187
|
+
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
|
|
188
|
+
// 创建合并后的 Int16Array
|
|
189
|
+
const merged = new Int16Array(totalLength);
|
|
190
|
+
let offset = 0;
|
|
191
|
+
for (const arr of arrays) {
|
|
192
|
+
// 将当前数组复制到目标数组的 offset 位置
|
|
193
|
+
merged.set(arr, offset);
|
|
194
|
+
offset += arr.length;
|
|
195
|
+
}
|
|
196
|
+
return merged;
|
|
197
|
+
}
|
|
198
|
+
static pcmToWav(data, sampleRate) {
|
|
199
|
+
function writeString(view, offset, type) {
|
|
200
|
+
for (let i = 0; i < type.length; i++) {
|
|
201
|
+
view.setUint8(offset + i, type.charCodeAt(i));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const numChannels = 1; // 单声道
|
|
205
|
+
const bytesPerSample = 2; // 16 位
|
|
206
|
+
const dataLength = data.length;
|
|
207
|
+
// 计算总文件大小
|
|
208
|
+
const fileSize = 44 + dataLength * bytesPerSample;
|
|
209
|
+
// 创建 ArrayBuffer
|
|
210
|
+
const buffer = new ArrayBuffer(fileSize);
|
|
211
|
+
const view = new DataView(buffer);
|
|
212
|
+
// 写入 RIFF 头
|
|
213
|
+
writeString(view, 0, 'RIFF');
|
|
214
|
+
view.setUint32(4, fileSize - 8, true); // 文件总长度 - 8
|
|
215
|
+
writeString(view, 8, 'WAVE');
|
|
216
|
+
// 写入 fmt 子块
|
|
217
|
+
writeString(view, 12, 'fmt ');
|
|
218
|
+
view.setUint32(16, 16, true); // 子块大小
|
|
219
|
+
view.setUint16(20, 1, true); // 音频格式 (PCM)
|
|
220
|
+
view.setUint16(22, numChannels, true); // 通道数
|
|
221
|
+
view.setUint32(24, sampleRate, true); // 采样率
|
|
222
|
+
view.setUint32(28, sampleRate * numChannels * bytesPerSample, true); // 字节率
|
|
223
|
+
view.setUint16(32, numChannels * bytesPerSample, true); // 块对齐
|
|
224
|
+
view.setUint16(34, bytesPerSample * 8, true); // 位深度 (16 位)
|
|
225
|
+
// 写入 data 子块
|
|
226
|
+
writeString(view, 36, 'data');
|
|
227
|
+
view.setUint32(40, dataLength * bytesPerSample, true); // 数据大小
|
|
228
|
+
// 写入 PCM 数据
|
|
229
|
+
for (let i = 0; i < dataLength; i++) {
|
|
230
|
+
view.setInt16(44 + i * bytesPerSample, data[i], true); // 小端序
|
|
231
|
+
}
|
|
232
|
+
const wavBlob = new Blob([buffer], { type: 'audio/wav' });
|
|
233
|
+
return wavBlob;
|
|
234
|
+
}
|
|
235
|
+
static isSpeaking(int16Array, sampleRate) {
|
|
236
|
+
const frameSize = Math.floor(0.02 * sampleRate); // 20ms 帧长度
|
|
237
|
+
const threshold = 0.01; // 能量阈值(需根据实际调整)
|
|
238
|
+
let speakingFrames = 0;
|
|
239
|
+
const frameCount = Math.ceil(int16Array.length / frameSize);
|
|
240
|
+
for (let i = 0; i < frameCount; i++) {
|
|
241
|
+
const start = i * frameSize;
|
|
242
|
+
const end = Math.min(start + frameSize, int16Array.length);
|
|
243
|
+
const frame = int16Array.slice(start, end);
|
|
244
|
+
let sum = 0;
|
|
245
|
+
for (let j = 0; j < frame.length; j++) {
|
|
246
|
+
const sample = frame[j] / 32768; // 归一化到 [-1, 1]
|
|
247
|
+
sum += sample * sample;
|
|
248
|
+
}
|
|
249
|
+
const rms = Math.sqrt(sum / frame.length);
|
|
250
|
+
if (rms > threshold) {
|
|
251
|
+
speakingFrames++;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// 如果超过 50% 的帧有语音,认为在说话
|
|
255
|
+
return speakingFrames / frameCount > 0.5;
|
|
256
|
+
}
|
|
257
|
+
static parseWav(buffer) {
|
|
258
|
+
// 将32位无符号整数转换为4字节字符串
|
|
259
|
+
function uint32ToChars(uint32) {
|
|
260
|
+
return String.fromCharCode(
|
|
261
|
+
(uint32 >> 24) & 0xff,
|
|
262
|
+
(uint32 >> 16) & 0xff,
|
|
263
|
+
(uint32 >> 8) & 0xff,
|
|
264
|
+
(uint32 >> 0) & 0xff,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
const dataView = new DataView(buffer);
|
|
268
|
+
let offset = 0;
|
|
269
|
+
// 1. 验证RIFF头
|
|
270
|
+
const riff = dataView.getUint32(offset, false); // 大端序
|
|
271
|
+
if (riff !== 0x52494646) {
|
|
272
|
+
// 'RIFF' in hex
|
|
273
|
+
throw new Error('Not a RIFF file');
|
|
274
|
+
}
|
|
275
|
+
offset += 4;
|
|
276
|
+
const fileSize = dataView.getUint32(offset, true); // 小端序
|
|
277
|
+
offset += 4;
|
|
278
|
+
const wave = dataView.getUint32(offset, false); // 大端序
|
|
279
|
+
if (wave !== 0x57415645) {
|
|
280
|
+
// 'WAVE' in hex
|
|
281
|
+
throw new Error('Not a WAVE file');
|
|
282
|
+
}
|
|
283
|
+
offset += 4;
|
|
284
|
+
// 2. 遍历块,查找'fmt '块
|
|
285
|
+
while (offset < buffer.byteLength) {
|
|
286
|
+
const chunkId = dataView.getUint32(offset, false); // 大端序
|
|
287
|
+
const chunkSize = dataView.getUint32(offset + 4, true); // 小端序
|
|
288
|
+
const chunkIdStr = uint32ToChars(chunkId);
|
|
289
|
+
if (chunkIdStr === 'fmt ') {
|
|
290
|
+
// 3. 解析'fmt '块数据
|
|
291
|
+
const audioFormat = dataView.getUint16(offset + 8, true); // 小端序
|
|
292
|
+
const numChannels = dataView.getUint16(offset + 10, true);
|
|
293
|
+
const sampleRate = dataView.getUint32(offset + 12, true);
|
|
294
|
+
const byteRate = dataView.getUint32(offset + 16, true);
|
|
295
|
+
return { numChannels, sampleRate, byteRate, fileSize, audioFormat };
|
|
296
|
+
}
|
|
297
|
+
// 移动到下一个块
|
|
298
|
+
offset += 8 + chunkSize;
|
|
299
|
+
}
|
|
300
|
+
throw new Error('fmt chunk not found');
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export { Recorder };
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "recorder-client",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "audio recording client",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.cjs.js",
|
|
7
|
+
"module": "dist/index.esm.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.esm.js",
|
|
16
|
+
"require": "./dist/index.cjs.js"
|
|
17
|
+
},
|
|
18
|
+
"./*": "./*"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "vitest",
|
|
22
|
+
"eslint": "eslint --fix **/*.{ts,js}",
|
|
23
|
+
"build": "rollup -c rollup.config.js"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@rollup/plugin-commonjs": "^28.0.6",
|
|
27
|
+
"@rollup/plugin-json": "^6.1.0",
|
|
28
|
+
"@rollup/plugin-node-resolve": "^16.0.1",
|
|
29
|
+
"@rollup/plugin-replace": "^6.0.2",
|
|
30
|
+
"@rollup/plugin-terser": "^0.4.4",
|
|
31
|
+
"@rollup/plugin-typescript": "^12.1.4",
|
|
32
|
+
"@types/node": "^24.3.0",
|
|
33
|
+
"happy-dom": "^18.0.1",
|
|
34
|
+
"rollup": "^4.47.1",
|
|
35
|
+
"rollup-plugin-dts": "^6.2.3",
|
|
36
|
+
"tslib": "^2.8.1",
|
|
37
|
+
"typescript": "^5.8.3",
|
|
38
|
+
"typescript-eslint-standard": "^9.59.0",
|
|
39
|
+
"vitest": "^3.2.4"
|
|
40
|
+
},
|
|
41
|
+
"author": "mivui",
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"keywords": [
|
|
44
|
+
"recorder",
|
|
45
|
+
"audio",
|
|
46
|
+
"MediaStream",
|
|
47
|
+
"audioWorklet",
|
|
48
|
+
"FunASR"
|
|
49
|
+
],
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "git+https://github.com/mivui/recorder-client.git"
|
|
53
|
+
},
|
|
54
|
+
"bugs": {
|
|
55
|
+
"url": "https://github.com/mivui/recorder-client/issues"
|
|
56
|
+
},
|
|
57
|
+
"homepage": "https://github.com/mivui/recorder-client#readme"
|
|
58
|
+
}
|