recorder-client 1.0.6 → 1.1.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 +41 -26
- package/dist/index.cjs.js +309 -237
- package/dist/index.d.ts +20 -3
- package/dist/index.esm.js +309 -237
- package/package.json +7 -10
package/README.md
CHANGED
|
@@ -14,11 +14,11 @@ npm i recorder-client
|
|
|
14
14
|
|
|
15
15
|
### API
|
|
16
16
|
|
|
17
|
-
| property | type
|
|
18
|
-
| :--------: |
|
|
19
|
-
| sampleRate | number
|
|
20
|
-
| chunkSize | number
|
|
21
|
-
|
|
|
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
|
+
| vad | Vad | determine whether the audio clip contains spoken content | undefined |
|
|
22
22
|
|
|
23
23
|
### Event
|
|
24
24
|
|
|
@@ -36,12 +36,11 @@ npm i recorder-client
|
|
|
36
36
|
|
|
37
37
|
### Helper
|
|
38
38
|
|
|
39
|
-
| property |
|
|
40
|
-
| :------: |
|
|
41
|
-
| pcmMerge |
|
|
42
|
-
| pcmToWav |
|
|
43
|
-
| parseWav |
|
|
44
|
-
| isSpeak | (pcm: Int16Array, sampleRate: number) => boolean | check if there is any input of audio |
|
|
39
|
+
| property | type | description |
|
|
40
|
+
| :------: | :-------------------------------------------: | :-----------------------------------: |
|
|
41
|
+
| pcmMerge | (pcms: Int16Array[]) => Int16Array | merge multiple pcm streams |
|
|
42
|
+
| pcmToWav | (pcm: Int16Array, sampleRate: number) => Blob | convert PCM to WAV |
|
|
43
|
+
| parseWav | (buffer: ArrayBuffer) => object | obtain information about the wav file |
|
|
45
44
|
|
|
46
45
|
### Example
|
|
47
46
|
|
|
@@ -69,6 +68,37 @@ const onClick = () => {
|
|
|
69
68
|
};
|
|
70
69
|
```
|
|
71
70
|
|
|
71
|
+
> VAD
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { Recorder } from 'recorder-client';
|
|
75
|
+
|
|
76
|
+
type VadScene =
|
|
77
|
+
| 'extreme-noise'
|
|
78
|
+
| 'high-noise'
|
|
79
|
+
| 'live-stream'
|
|
80
|
+
| 'normal-indoor'
|
|
81
|
+
| 'quiet-studio'
|
|
82
|
+
| 'voice-assistant';
|
|
83
|
+
|
|
84
|
+
const recorder = new Recorder({
|
|
85
|
+
sampleRate: 16000,
|
|
86
|
+
chunkSize: 1900,
|
|
87
|
+
vad: 'normal-indoor',
|
|
88
|
+
});
|
|
89
|
+
// custom
|
|
90
|
+
const recorder = new Recorder({
|
|
91
|
+
sampleRate: 16000,
|
|
92
|
+
chunkSize: 1900,
|
|
93
|
+
vad: {
|
|
94
|
+
noiseCoefficient: 2.3,
|
|
95
|
+
voiceRatioThreshold: 0.5,
|
|
96
|
+
noiseFrameCount: 5,
|
|
97
|
+
frameDurationMs: 20,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
72
102
|
> pcmMerge
|
|
73
103
|
|
|
74
104
|
```ts
|
|
@@ -123,18 +153,3 @@ const getWav = async (file: File) => {
|
|
|
123
153
|
const wav: Wav = Recorder.parseWav(buffer);
|
|
124
154
|
};
|
|
125
155
|
```
|
|
126
|
-
|
|
127
|
-
> isSpeak
|
|
128
|
-
|
|
129
|
-
```ts
|
|
130
|
-
import { Recorder } from 'recorder-client';
|
|
131
|
-
|
|
132
|
-
const recorder = new Recorder({
|
|
133
|
-
sampleRate: 16000,
|
|
134
|
-
chunkSize: 1900,
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
recorder.ondataavailable = (pcm) => {
|
|
138
|
-
const flag = Recorder.isSpeak(pcm, 16000);
|
|
139
|
-
};
|
|
140
|
-
```
|
package/dist/index.cjs.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
//AudioWorkletProcessor 单声道 音频采样率是16000 使用pcm 1.9kb发送一次数据
|
|
4
4
|
function audioProcessorJs() {
|
|
5
|
-
|
|
5
|
+
const code = `
|
|
6
6
|
class AudioProcessor extends AudioWorkletProcessor {
|
|
7
7
|
constructor({ processorOptions }) {
|
|
8
8
|
super();
|
|
@@ -56,251 +56,323 @@ class AudioProcessor extends AudioWorkletProcessor {
|
|
|
56
56
|
|
|
57
57
|
registerProcessor('audio-processor', AudioProcessor);
|
|
58
58
|
`;
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
const blob = new Blob([code], { type: 'application/javascript' });
|
|
60
|
+
return URL.createObjectURL(blob);
|
|
61
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
|
+
};
|
|
62
101
|
class Recorder {
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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.isSpeak(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.pcmMerge(this._pcmData));
|
|
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;
|
|
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 = [];
|
|
197
171
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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?.();
|
|
205
204
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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); // 小端序
|
|
205
|
+
pause() {
|
|
206
|
+
this._workletNode?.port.postMessage('pause');
|
|
207
|
+
this.onpause?.();
|
|
233
208
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
static isSpeak(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
|
-
}
|
|
209
|
+
resume() {
|
|
210
|
+
this._workletNode?.port.postMessage('resume');
|
|
211
|
+
this.onresume?.();
|
|
255
212
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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 = [];
|
|
268
226
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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;
|
|
276
239
|
}
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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;
|
|
284
276
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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');
|
|
301
375
|
}
|
|
302
|
-
throw new Error('fmt chunk not found');
|
|
303
|
-
}
|
|
304
376
|
}
|
|
305
377
|
|
|
306
378
|
exports.Recorder = Recorder;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
|
+
/** 预定义的VAD场景类型 */
|
|
2
|
+
type VadScene = 'extreme-noise' | 'high-noise' | 'live-stream' | 'normal-indoor' | 'quiet-studio' | 'voice-assistant';
|
|
3
|
+
/** VAD配置选项类型 */
|
|
4
|
+
interface VadOptions {
|
|
5
|
+
noiseCoefficient: number;
|
|
6
|
+
voiceRatioThreshold: number;
|
|
7
|
+
noiseFrameCount: number;
|
|
8
|
+
frameDurationMs?: number;
|
|
9
|
+
}
|
|
10
|
+
type Vad = Partial<VadOptions> | VadScene;
|
|
1
11
|
interface RecorderConstructor {
|
|
2
12
|
sampleRate: number;
|
|
3
13
|
chunkSize: number;
|
|
4
|
-
|
|
14
|
+
vad?: Vad;
|
|
5
15
|
}
|
|
6
16
|
declare class Recorder {
|
|
7
17
|
private _stream?;
|
|
@@ -22,7 +32,14 @@ declare class Recorder {
|
|
|
22
32
|
stop(): void;
|
|
23
33
|
static pcmMerge(arrays: Int16Array[]): Int16Array;
|
|
24
34
|
static pcmToWav(data: Int16Array, sampleRate: number): Blob;
|
|
25
|
-
|
|
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;
|
|
26
43
|
static parseWav(buffer: ArrayBuffer): {
|
|
27
44
|
numChannels: number;
|
|
28
45
|
sampleRate: number;
|
|
@@ -33,4 +50,4 @@ declare class Recorder {
|
|
|
33
50
|
}
|
|
34
51
|
|
|
35
52
|
export { Recorder };
|
|
36
|
-
export type { RecorderConstructor };
|
|
53
|
+
export type { RecorderConstructor, Vad, VadOptions, VadScene };
|
package/dist/index.esm.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
//AudioWorkletProcessor 单声道 音频采样率是16000 使用pcm 1.9kb发送一次数据
|
|
2
2
|
function audioProcessorJs() {
|
|
3
|
-
|
|
3
|
+
const code = `
|
|
4
4
|
class AudioProcessor extends AudioWorkletProcessor {
|
|
5
5
|
constructor({ processorOptions }) {
|
|
6
6
|
super();
|
|
@@ -54,251 +54,323 @@ class AudioProcessor extends AudioWorkletProcessor {
|
|
|
54
54
|
|
|
55
55
|
registerProcessor('audio-processor', AudioProcessor);
|
|
56
56
|
`;
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
const blob = new Blob([code], { type: 'application/javascript' });
|
|
58
|
+
return URL.createObjectURL(blob);
|
|
59
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
|
+
};
|
|
60
99
|
class Recorder {
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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.isSpeak(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.pcmMerge(this._pcmData));
|
|
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;
|
|
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 = [];
|
|
195
169
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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?.();
|
|
203
202
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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); // 小端序
|
|
203
|
+
pause() {
|
|
204
|
+
this._workletNode?.port.postMessage('pause');
|
|
205
|
+
this.onpause?.();
|
|
231
206
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
static isSpeak(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
|
-
}
|
|
207
|
+
resume() {
|
|
208
|
+
this._workletNode?.port.postMessage('resume');
|
|
209
|
+
this.onresume?.();
|
|
253
210
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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 = [];
|
|
266
224
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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;
|
|
274
237
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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;
|
|
282
274
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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');
|
|
299
373
|
}
|
|
300
|
-
throw new Error('fmt chunk not found');
|
|
301
|
-
}
|
|
302
374
|
}
|
|
303
375
|
|
|
304
376
|
export { Recorder };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "recorder-client",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "audio recording client",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs.js",
|
|
@@ -18,8 +18,6 @@
|
|
|
18
18
|
"./*": "./*"
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
|
-
"test": "vitest",
|
|
22
|
-
"eslint": "eslint --fix **/*.{ts,js}",
|
|
23
21
|
"build": "rollup -c rollup.config.js"
|
|
24
22
|
},
|
|
25
23
|
"devDependencies": {
|
|
@@ -29,19 +27,18 @@
|
|
|
29
27
|
"@rollup/plugin-replace": "^6.0.3",
|
|
30
28
|
"@rollup/plugin-terser": "^0.4.4",
|
|
31
29
|
"@rollup/plugin-typescript": "^12.3.0",
|
|
32
|
-
"@types/node": "^24.
|
|
33
|
-
"happy-dom": "^20.0
|
|
34
|
-
"rollup": "^4.
|
|
35
|
-
"rollup-plugin-dts": "^6.
|
|
30
|
+
"@types/node": "^24.10.7",
|
|
31
|
+
"happy-dom": "^20.1.0",
|
|
32
|
+
"rollup": "^4.55.1",
|
|
33
|
+
"rollup-plugin-dts": "^6.3.0",
|
|
36
34
|
"tslib": "^2.8.1",
|
|
37
35
|
"typescript": "^5.9.3",
|
|
38
|
-
"typescript-eslint-standard": "^9.
|
|
39
|
-
"vitest": "^4.0.
|
|
36
|
+
"typescript-eslint-standard": "^9.75.0",
|
|
37
|
+
"vitest": "^4.0.16"
|
|
40
38
|
},
|
|
41
39
|
"author": "mivui",
|
|
42
40
|
"license": "MIT",
|
|
43
41
|
"keywords": [
|
|
44
|
-
"recorder",
|
|
45
42
|
"audio",
|
|
46
43
|
"MediaStream",
|
|
47
44
|
"audioWorklet",
|