recorder-client 1.0.7 → 1.1.1

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
@@ -14,11 +14,11 @@ npm i recorder-client
14
14
 
15
15
  ### API
16
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 | undefined |
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 | 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 |
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
- //参考 AudioWorkletProcessor 单声道 音频采样率是16000 使用pcm 1.9kb发送一次数据
3
+ //AudioWorkletProcessor 单声道 音频采样率是16000 使用pcm 1.9kb发送一次数据
4
4
  function audioProcessorJs() {
5
- const code = `
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
- const blob = new Blob([code], { type: 'application/javascript' });
60
- return URL.createObjectURL(blob);
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
- 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.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
- 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
- }
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
- 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); // 小端序
205
+ pause() {
206
+ this._workletNode?.port.postMessage('pause');
207
+ this.onpause?.();
233
208
  }
234
- const wavBlob = new Blob([buffer], { type: 'audio/wav' });
235
- return wavBlob;
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
- // 如果超过 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
- );
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
- 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');
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
- 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');
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
- 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;
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
- ignoreMute?: boolean;
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
- static isSpeak(int16Array: Int16Array, sampleRate: number): boolean;
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
- //参考 AudioWorkletProcessor 单声道 音频采样率是16000 使用pcm 1.9kb发送一次数据
1
+ //AudioWorkletProcessor 单声道 音频采样率是16000 使用pcm 1.9kb发送一次数据
2
2
  function audioProcessorJs() {
3
- const code = `
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
- const blob = new Blob([code], { type: 'application/javascript' });
58
- return URL.createObjectURL(blob);
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
- 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.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
- 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
- }
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
- 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); // 小端序
203
+ pause() {
204
+ this._workletNode?.port.postMessage('pause');
205
+ this.onpause?.();
231
206
  }
232
- const wavBlob = new Blob([buffer], { type: 'audio/wav' });
233
- return wavBlob;
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
- // 如果超过 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
- );
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
- 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');
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
- 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');
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
- 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;
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.7",
3
+ "version": "1.1.1",
4
4
  "description": "audio recording client",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs.js",
@@ -18,25 +18,22 @@
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": {
26
- "@rollup/plugin-commonjs": "^29.0.0",
24
+ "@rollup/plugin-commonjs": "^29.0.2",
27
25
  "@rollup/plugin-json": "^6.1.0",
28
26
  "@rollup/plugin-node-resolve": "^16.0.3",
29
27
  "@rollup/plugin-replace": "^6.0.3",
30
- "@rollup/plugin-terser": "^0.4.4",
28
+ "@rollup/plugin-terser": "^1.0.0",
31
29
  "@rollup/plugin-typescript": "^12.3.0",
32
- "@types/node": "^24.10.1",
33
- "happy-dom": "^20.0.11",
34
- "rollup": "^4.53.3",
35
- "rollup-plugin-dts": "^6.3.0",
30
+ "@types/node": "^24.12.0",
31
+ "rollup": "^4.59.0",
32
+ "rollup-plugin-dts": "^6.4.0",
36
33
  "tslib": "^2.8.1",
37
34
  "typescript": "^5.9.3",
38
- "typescript-eslint-standard": "^9.67.0",
39
- "vitest": "^4.0.15"
35
+ "typescript-eslint-standard": "^10.0.1",
36
+ "vitest": "^4.1.0"
40
37
  },
41
38
  "author": "mivui",
42
39
  "license": "MIT",
@@ -44,7 +41,9 @@
44
41
  "audio",
45
42
  "MediaStream",
46
43
  "audioWorklet",
47
- "funasr"
44
+ "funasr",
45
+ "qwen",
46
+ "asr"
48
47
  ],
49
48
  "repository": {
50
49
  "type": "git",