sur-voice 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/.vscode/extensions.json +3 -0
- package/README.md +5 -0
- package/dist/sur-voice.es.js +641 -0
- package/dist/sur-voice.umd.js +32 -0
- package/dist/vite.svg +1 -0
- package/index.html +13 -0
- package/package.json +30 -0
- package/public/vite.svg +1 -0
- package/src/App.vue +30 -0
- package/src/assets/vue.svg +1 -0
- package/src/components/surVoice.vue +391 -0
- package/src/index.ts +18 -0
- package/src/main.ts +5 -0
- package/src/style.css +79 -0
- package/src/utils/ChatUtils.ts +63 -0
- package/src/utils/Recorder.js +628 -0
- package/tsconfig.app.json +16 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +43 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
const ChatUtils = {
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 图片加载完成,聊天对话框scroll拉到最下
|
|
8
|
+
* @param id 容器id
|
|
9
|
+
*/
|
|
10
|
+
imageLoad(id: string): void {
|
|
11
|
+
this.scrollBottom(id);
|
|
12
|
+
const messageBox = document.getElementById(id);
|
|
13
|
+
if (messageBox) {
|
|
14
|
+
const images = messageBox.getElementsByTagName("img");
|
|
15
|
+
if (images) {
|
|
16
|
+
const arr: string[] = [];
|
|
17
|
+
for (let i = 0; i < images.length; i++) {
|
|
18
|
+
arr[i] = images[i].src;
|
|
19
|
+
}
|
|
20
|
+
this.preloadImages(arr).finally(() => {
|
|
21
|
+
this.scrollBottom(id);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 加载图片
|
|
29
|
+
* @param arr 图片路径[]
|
|
30
|
+
*/
|
|
31
|
+
preloadImages(arr: string[]): Promise<any> {
|
|
32
|
+
let loadedImage = 0;
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
for (let i = 0; i < arr.length; i++) {
|
|
35
|
+
const image = new Image();
|
|
36
|
+
image.src = arr[i];
|
|
37
|
+
image.onload = () => {
|
|
38
|
+
loadedImage++;
|
|
39
|
+
if (loadedImage === arr.length) {
|
|
40
|
+
resolve("");
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
image.onerror = () => {
|
|
44
|
+
loadedImage++;
|
|
45
|
+
if (loadedImage === arr.length) {
|
|
46
|
+
resolve("");
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
/**
|
|
53
|
+
* 滚动条到最下方
|
|
54
|
+
* @param id 容器id
|
|
55
|
+
*/
|
|
56
|
+
scrollBottom(id: string): void {
|
|
57
|
+
const div = document.getElementById(id);
|
|
58
|
+
if (div) {
|
|
59
|
+
div.scrollTop = div.scrollHeight;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
export default ChatUtils;
|
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @Time : 2019/03/08 17:09:53
|
|
3
|
+
* @Author : wyh19
|
|
4
|
+
* @Contact : wyh_19@163.com
|
|
5
|
+
* @Desc : 录音器类,支持AudioWorklet和ScriptProcessorNode降级方案
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
class Recorder {
|
|
9
|
+
constructor(onaudioprocess, showWaveFn) {
|
|
10
|
+
this.config = {
|
|
11
|
+
sampleBits: 16, // 采样数位 8, 16
|
|
12
|
+
sampleRate: 16000, // 采样率(1/6 44100)
|
|
13
|
+
};
|
|
14
|
+
this.size = 0; // 录音文件总长度
|
|
15
|
+
this.buffer = []; // 录音缓存
|
|
16
|
+
this.realtimeBuffer = [];
|
|
17
|
+
this.audioContext = null;
|
|
18
|
+
this.mediaStream = null;
|
|
19
|
+
this.mediaStreamSource = null;
|
|
20
|
+
this.audioWorkletNode = null;
|
|
21
|
+
this.scriptProcessor = null;
|
|
22
|
+
this.isRecording = false;
|
|
23
|
+
|
|
24
|
+
// 录音实时获取数据
|
|
25
|
+
this.input = function(data) {
|
|
26
|
+
// 记录数据,这儿的buffer是二维的
|
|
27
|
+
this.buffer.push(new Float32Array(data));
|
|
28
|
+
this.size += data.length;
|
|
29
|
+
};
|
|
30
|
+
this.onaudioprocess = onaudioprocess;
|
|
31
|
+
this.showWaveFn = showWaveFn;
|
|
32
|
+
|
|
33
|
+
// 检查是否支持 AudioWorklet
|
|
34
|
+
this.supportsAudioWorklet =
|
|
35
|
+
typeof AudioWorkletNode !== 'undefined' &&
|
|
36
|
+
typeof OfflineAudioContext !== 'undefined';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 设置如采样位数的参数
|
|
40
|
+
setOption(option) {
|
|
41
|
+
// 修改采样率,采样位数配置
|
|
42
|
+
Object.assign(this.config, option);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async ready() {
|
|
46
|
+
try {
|
|
47
|
+
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
48
|
+
|
|
49
|
+
if (!navigator.mediaDevices) {
|
|
50
|
+
return Promise.reject(new Error('无法发现指定的硬件设备。'));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 获取麦克风权限
|
|
54
|
+
this.mediaStream = await navigator.mediaDevices.getUserMedia({
|
|
55
|
+
audio: {
|
|
56
|
+
echoCancellation: false,
|
|
57
|
+
noiseSuppression: false,
|
|
58
|
+
autoGainControl: false,
|
|
59
|
+
sampleRate: this.config.sampleRate,
|
|
60
|
+
channelCount: 1
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// 创建音频源
|
|
65
|
+
this.mediaStreamSource = this.audioContext.createMediaStreamSource(this.mediaStream);
|
|
66
|
+
|
|
67
|
+
// 设置采样率
|
|
68
|
+
this.inputSampleRate = this.audioContext.sampleRate;
|
|
69
|
+
this.inputSampleBits = 16;
|
|
70
|
+
this.outputSampleRate = this.config.sampleRate;
|
|
71
|
+
this.oututSampleBits = this.config.sampleBits;
|
|
72
|
+
|
|
73
|
+
return Promise.resolve();
|
|
74
|
+
} catch (error) {
|
|
75
|
+
this.handleError(error);
|
|
76
|
+
return Promise.reject(error);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 开始录音
|
|
81
|
+
async start() {
|
|
82
|
+
try {
|
|
83
|
+
if (!this.audioContext || this.audioContext.state === 'closed') {
|
|
84
|
+
await this.ready();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (this.audioContext.state === 'suspended') {
|
|
88
|
+
await this.audioContext.resume();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 清空数据
|
|
92
|
+
this.buffer.length = 0;
|
|
93
|
+
this.size = 0;
|
|
94
|
+
this.isRecording = true;
|
|
95
|
+
|
|
96
|
+
if (this.supportsAudioWorklet) {
|
|
97
|
+
// 使用 AudioWorklet
|
|
98
|
+
await this.setupAudioWorklet();
|
|
99
|
+
} else {
|
|
100
|
+
// 降级使用 ScriptProcessorNode(兼容旧浏览器)
|
|
101
|
+
this.setupScriptProcessor();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
} catch (error) {
|
|
105
|
+
Recorder.throwError('无法开始录音。异常信息:' + (error.code || error.name));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 设置 AudioWorklet
|
|
110
|
+
async setupAudioWorklet() {
|
|
111
|
+
try {
|
|
112
|
+
// 创建内联 AudioWorklet 处理器
|
|
113
|
+
const workletProcessor = `
|
|
114
|
+
class RecorderProcessor extends AudioWorkletProcessor {
|
|
115
|
+
constructor(options) {
|
|
116
|
+
super();
|
|
117
|
+
this.sampleRate = options.processorOptions?.sampleRate || 16000;
|
|
118
|
+
this.isRecording = true;
|
|
119
|
+
|
|
120
|
+
this.port.onmessage = (event) => {
|
|
121
|
+
if (event.data.type === 'stop') {
|
|
122
|
+
this.isRecording = false;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
process(inputs) {
|
|
128
|
+
if (!this.isRecording) return false;
|
|
129
|
+
|
|
130
|
+
const input = inputs[0];
|
|
131
|
+
if (input && input.length > 0) {
|
|
132
|
+
const channelData = input[0];
|
|
133
|
+
// 发送音频数据到主线程
|
|
134
|
+
this.port.postMessage({
|
|
135
|
+
type: 'audioData',
|
|
136
|
+
audioData: channelData.slice()
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
registerProcessor('recorder-processor', RecorderProcessor);
|
|
144
|
+
`;
|
|
145
|
+
|
|
146
|
+
// 使用 Blob URL 注册 AudioWorklet
|
|
147
|
+
const blob = new Blob([workletProcessor], { type: 'application/javascript' });
|
|
148
|
+
const url = URL.createObjectURL(blob);
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
await this.audioContext.audioWorklet.addModule(url);
|
|
152
|
+
} finally {
|
|
153
|
+
URL.revokeObjectURL(url);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 创建 AudioWorkletNode
|
|
157
|
+
this.audioWorkletNode = new AudioWorkletNode(this.audioContext, 'recorder-processor', {
|
|
158
|
+
numberOfInputs: 1,
|
|
159
|
+
numberOfOutputs: 1,
|
|
160
|
+
outputChannelCount: [1],
|
|
161
|
+
processorOptions: {
|
|
162
|
+
sampleRate: this.config.sampleRate
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// 处理接收到的音频数据
|
|
167
|
+
this.audioWorkletNode.port.onmessage = (event) => {
|
|
168
|
+
if (!this.isRecording) return;
|
|
169
|
+
|
|
170
|
+
const data = event.data;
|
|
171
|
+
if (data.type === 'audioData') {
|
|
172
|
+
this.handleAudioData(data.audioData);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// 连接节点
|
|
177
|
+
this.mediaStreamSource.connect(this.audioWorkletNode);
|
|
178
|
+
this.audioWorkletNode.connect(this.audioContext.destination);
|
|
179
|
+
|
|
180
|
+
} catch (error) {
|
|
181
|
+
console.warn('AudioWorklet 初始化失败,降级使用 ScriptProcessorNode:', error);
|
|
182
|
+
this.setupScriptProcessor();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 降级使用 ScriptProcessorNode
|
|
187
|
+
setupScriptProcessor() {
|
|
188
|
+
console.warn('使用 ScriptProcessorNode(已废弃,建议升级浏览器)');
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const createScript =
|
|
192
|
+
this.audioContext.createScriptProcessor ||
|
|
193
|
+
this.audioContext.createJavaScriptNode;
|
|
194
|
+
|
|
195
|
+
this.scriptProcessor = createScript.call(this.audioContext, 4096, 1, 1);
|
|
196
|
+
|
|
197
|
+
// 音频采集
|
|
198
|
+
this.scriptProcessor.onaudioprocess = (e) => {
|
|
199
|
+
if (!this.isRecording) return;
|
|
200
|
+
|
|
201
|
+
const data = e.inputBuffer.getChannelData(0);
|
|
202
|
+
this.handleAudioData(data);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// 连接节点
|
|
206
|
+
this.mediaStreamSource.connect(this.scriptProcessor);
|
|
207
|
+
this.scriptProcessor.connect(this.audioContext.destination);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
Recorder.throwError('无法创建音频处理器: ' + error.message);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 处理音频数据
|
|
214
|
+
handleAudioData(audioData) {
|
|
215
|
+
if (!this.isRecording) return;
|
|
216
|
+
|
|
217
|
+
this.input(audioData);
|
|
218
|
+
if (this.onaudioprocess) {
|
|
219
|
+
this.onaudioprocess(this.encodePCMFragment(audioData));
|
|
220
|
+
}
|
|
221
|
+
if (this.showWaveFn) {
|
|
222
|
+
this.toWaveData(audioData);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 停止录音
|
|
227
|
+
stop() {
|
|
228
|
+
this.isRecording = false;
|
|
229
|
+
|
|
230
|
+
// 停止 AudioWorklet
|
|
231
|
+
if (this.audioWorkletNode) {
|
|
232
|
+
this.audioWorkletNode.port.postMessage({ type: 'stop' });
|
|
233
|
+
this.audioWorkletNode.disconnect();
|
|
234
|
+
this.audioWorkletNode = null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 停止 ScriptProcessor
|
|
238
|
+
if (this.scriptProcessor) {
|
|
239
|
+
this.scriptProcessor.disconnect();
|
|
240
|
+
this.scriptProcessor = null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 断开媒体流
|
|
244
|
+
if (this.mediaStreamSource) {
|
|
245
|
+
this.mediaStreamSource.disconnect();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 停止音轨(但不关闭AudioContext,以便重新开始)
|
|
249
|
+
if (this.mediaStream) {
|
|
250
|
+
this.mediaStream.getTracks().forEach(track => {
|
|
251
|
+
track.stop();
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 完全关闭(释放所有资源)
|
|
257
|
+
close() {
|
|
258
|
+
this.stop();
|
|
259
|
+
|
|
260
|
+
if (this.audioContext && this.audioContext.state !== 'closed') {
|
|
261
|
+
this.audioContext.close();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
this.audioContext = null;
|
|
265
|
+
this.mediaStream = null;
|
|
266
|
+
this.mediaStreamSource = null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 错误处理
|
|
270
|
+
handleError(error) {
|
|
271
|
+
console.error('录音错误:', error);
|
|
272
|
+
|
|
273
|
+
const errorMap = {
|
|
274
|
+
'PERMISSION_DENIED': '用户拒绝提供信息。',
|
|
275
|
+
'PermissionDeniedError': '用户拒绝提供信息。',
|
|
276
|
+
'NOT_SUPPORTED_ERROR': '浏览器不支持硬件设备。',
|
|
277
|
+
'NotSupportedError': '浏览器不支持硬件设备。',
|
|
278
|
+
'MANDATORY_UNSATISFIED_ERROR': '无法发现指定的硬件设备。',
|
|
279
|
+
'MandatoryUnsatisfiedError': '无法发现指定的硬件设备。',
|
|
280
|
+
'NotAllowedError': '请在浏览器设置中开启麦克风权限!',
|
|
281
|
+
'NotFoundError': '无法发现指定的硬件设备。',
|
|
282
|
+
8: '无法发现指定的硬件设备。',
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const message = errorMap[error.code || error.name] ||
|
|
286
|
+
`无法打开麦克风。异常信息: ${error.code || error.name || error.message}`;
|
|
287
|
+
|
|
288
|
+
Recorder.throwError(message);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 播放到audio标签中
|
|
292
|
+
play(audio) {
|
|
293
|
+
if (!audio) return;
|
|
294
|
+
const wavBlob = this.getWAVBlob();
|
|
295
|
+
if (wavBlob) {
|
|
296
|
+
audio.src = URL.createObjectURL(wavBlob);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 获取PCM编码的二进制数据
|
|
301
|
+
getPCM() {
|
|
302
|
+
this.stop();
|
|
303
|
+
return this.encodePCM();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// 获取不压缩的PCM格式的编码
|
|
307
|
+
getPCMBlob() {
|
|
308
|
+
const pcmData = this.getPCM();
|
|
309
|
+
return pcmData ? new Blob([pcmData]) : null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// 获取WAV编码的二进制数据
|
|
313
|
+
getWAV(isRecord = true) {
|
|
314
|
+
this.stop();
|
|
315
|
+
return this.encodeWAV(isRecord);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 获取不压缩的WAV格式的编码
|
|
319
|
+
getWAVBlob() {
|
|
320
|
+
const wavData = this.getWAV(true);
|
|
321
|
+
return wavData ? new Blob([wavData], { type: 'audio/wav' }) : null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 采样率转换
|
|
325
|
+
SRC(input, inputFs, outputFs) {
|
|
326
|
+
// 输入为空检验
|
|
327
|
+
if (input == null) {
|
|
328
|
+
throw Error('Error:\t输入音频为空数组');
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// 采样率合法检验
|
|
333
|
+
if (inputFs <= 1 || outputFs <= 1) {
|
|
334
|
+
throw Error('Error:\t输入或输出音频采样率不合法');
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// 输入音频长度
|
|
339
|
+
let len = input.length;
|
|
340
|
+
|
|
341
|
+
// 输出音频长度
|
|
342
|
+
let outlen = Math.round((len * outputFs) / inputFs);
|
|
343
|
+
|
|
344
|
+
let output = new Float32Array(outlen);
|
|
345
|
+
let S = new Float32Array(len);
|
|
346
|
+
let T = new Float32Array(outlen);
|
|
347
|
+
// 输入信号归一化
|
|
348
|
+
for (let i = 0; i < len; i++) {
|
|
349
|
+
S[i] = input[i] / 32768.0;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// 计算输入输出个数比
|
|
353
|
+
let F = (len - 1) / (outlen - 1);
|
|
354
|
+
let Fn = 0;
|
|
355
|
+
let Ceil = 0,
|
|
356
|
+
Floor = 0;
|
|
357
|
+
output[0] = input[0];
|
|
358
|
+
for (let n = 1; n < outlen; n++) {
|
|
359
|
+
// 计算输出对应输入的相邻下标
|
|
360
|
+
Fn = F * n;
|
|
361
|
+
Ceil = Math.ceil(Fn);
|
|
362
|
+
Floor = Math.floor(Fn);
|
|
363
|
+
|
|
364
|
+
// 防止下标溢出
|
|
365
|
+
if (Ceil >= len && Floor < len) {
|
|
366
|
+
Ceil = Floor;
|
|
367
|
+
} else if (Ceil >= len && Floor >= len) {
|
|
368
|
+
Ceil = len - 1;
|
|
369
|
+
Floor = len - 1;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// 相似三角形法计算输出点近似值
|
|
373
|
+
T[n] = S[Floor] + (Fn - Floor) * (S[Ceil] - S[Floor]);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
for (let i = 1; i < outlen; i++) {
|
|
377
|
+
output[i] = T[i] * 32768.0;
|
|
378
|
+
}
|
|
379
|
+
return output;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// 数据合并压缩
|
|
383
|
+
compress(isReplay = false) {
|
|
384
|
+
if (this.buffer.length === 0) return new Float32Array(0);
|
|
385
|
+
|
|
386
|
+
// 合并
|
|
387
|
+
var data = new Float32Array(this.size);
|
|
388
|
+
var offset = 0; // 偏移量计算
|
|
389
|
+
// 将二维数据,转成一维数据
|
|
390
|
+
for (var i = 0; i < this.buffer.length; i++) {
|
|
391
|
+
data.set(this.buffer[i], offset);
|
|
392
|
+
offset += this.buffer[i].length;
|
|
393
|
+
}
|
|
394
|
+
let result = this.SRC(data, this.inputSampleRate, this.outputSampleRate);
|
|
395
|
+
return result;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// 编码PCM数据
|
|
399
|
+
encodePCM(isReplay = false) {
|
|
400
|
+
let bytes = this.compress(isReplay);
|
|
401
|
+
if (!bytes || bytes.length === 0) return null;
|
|
402
|
+
|
|
403
|
+
let sampleBits = Math.min(
|
|
404
|
+
this.inputSampleBits,
|
|
405
|
+
isReplay ? this.inputSampleBits : this.oututSampleBits,
|
|
406
|
+
);
|
|
407
|
+
let offset = 0;
|
|
408
|
+
let dataLength = bytes.length * (sampleBits / 8);
|
|
409
|
+
let buffer = new ArrayBuffer(dataLength);
|
|
410
|
+
let data = new DataView(buffer);
|
|
411
|
+
|
|
412
|
+
// 写入采样数据
|
|
413
|
+
if (sampleBits === 8) {
|
|
414
|
+
for (var i = 0; i < bytes.length; i++, offset++) {
|
|
415
|
+
// 范围[-1, 1]
|
|
416
|
+
var s = Math.max(-1, Math.min(1, bytes[i]));
|
|
417
|
+
var val = s < 0 ? s * 128 : s * 127;
|
|
418
|
+
val = parseInt(val + 128);
|
|
419
|
+
data.setInt8(offset, val, true);
|
|
420
|
+
}
|
|
421
|
+
} else {
|
|
422
|
+
for (var i = 0; i < bytes.length; i++, offset += 2) {
|
|
423
|
+
var s = Math.max(-1, Math.min(1, bytes[i]));
|
|
424
|
+
// 16位直接乘就行了
|
|
425
|
+
data.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return data;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// 编码PCM片段
|
|
433
|
+
encodePCMFragment(fragment) {
|
|
434
|
+
if (!fragment || fragment.length === 0) return null;
|
|
435
|
+
|
|
436
|
+
let data = new Float32Array(fragment);
|
|
437
|
+
let bytes = this.SRC(data, this.inputSampleRate, this.outputSampleRate);
|
|
438
|
+
if (!bytes || bytes.length === 0) return null;
|
|
439
|
+
|
|
440
|
+
let sampleBits = Math.min(this.inputSampleBits, this.oututSampleBits);
|
|
441
|
+
let offset = 0;
|
|
442
|
+
let dataLength = bytes.length * (sampleBits / 8);
|
|
443
|
+
let buffer = new ArrayBuffer(dataLength);
|
|
444
|
+
let lastData = new DataView(buffer);
|
|
445
|
+
|
|
446
|
+
// 写入采样数据
|
|
447
|
+
if (sampleBits === 8) {
|
|
448
|
+
for (var i = 0; i < bytes.length; i++, offset++) {
|
|
449
|
+
var s = Math.max(-1, Math.min(1, bytes[i]));
|
|
450
|
+
var val = s < 0 ? s * 128 : s * 127;
|
|
451
|
+
val = parseInt(val + 128);
|
|
452
|
+
lastData.setInt8(offset, val, true);
|
|
453
|
+
}
|
|
454
|
+
} else {
|
|
455
|
+
for (var i = 0; i < bytes.length; i++, offset += 2) {
|
|
456
|
+
var s = Math.max(-1, Math.min(1, bytes[i]));
|
|
457
|
+
lastData.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return lastData;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// 编码WAV数据
|
|
465
|
+
encodeWAV(isReplay = false) {
|
|
466
|
+
let bytes = this.encodePCM(isReplay);
|
|
467
|
+
if (!bytes) return null;
|
|
468
|
+
|
|
469
|
+
let sampleRate = this.outputSampleRate;
|
|
470
|
+
let sampleBits = Math.min(
|
|
471
|
+
this.inputSampleBits,
|
|
472
|
+
isReplay ? this.inputSampleBits : this.oututSampleBits,
|
|
473
|
+
);
|
|
474
|
+
let buffer = new ArrayBuffer(44);
|
|
475
|
+
let data = new DataView(buffer);
|
|
476
|
+
|
|
477
|
+
let channelCount = 1; // 单声道
|
|
478
|
+
let offset = 0;
|
|
479
|
+
|
|
480
|
+
// 写入WAV头
|
|
481
|
+
writeString(data, offset, 'RIFF');
|
|
482
|
+
offset += 4;
|
|
483
|
+
data.setUint32(offset, 36 + bytes.byteLength, true);
|
|
484
|
+
offset += 4;
|
|
485
|
+
writeString(data, offset, 'WAVE');
|
|
486
|
+
offset += 4;
|
|
487
|
+
writeString(data, offset, 'fmt ');
|
|
488
|
+
offset += 4;
|
|
489
|
+
data.setUint32(offset, 16, true);
|
|
490
|
+
offset += 4;
|
|
491
|
+
data.setUint16(offset, 1, true);
|
|
492
|
+
offset += 2;
|
|
493
|
+
data.setUint16(offset, channelCount, true);
|
|
494
|
+
offset += 2;
|
|
495
|
+
data.setUint32(offset, sampleRate, true);
|
|
496
|
+
offset += 4;
|
|
497
|
+
data.setUint32(offset, channelCount * sampleRate * (sampleBits / 8), true);
|
|
498
|
+
offset += 4;
|
|
499
|
+
data.setUint16(offset, channelCount * (sampleBits / 8), true);
|
|
500
|
+
offset += 2;
|
|
501
|
+
data.setUint16(offset, sampleBits, true);
|
|
502
|
+
offset += 2;
|
|
503
|
+
writeString(data, offset, 'data');
|
|
504
|
+
offset += 4;
|
|
505
|
+
data.setUint32(offset, bytes.byteLength, true);
|
|
506
|
+
offset += 4;
|
|
507
|
+
|
|
508
|
+
// 合并WAV头和PCM数据
|
|
509
|
+
data = combineDataView(DataView, data, bytes);
|
|
510
|
+
|
|
511
|
+
return data;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// 转换为波形数据
|
|
515
|
+
toWaveData(o) {
|
|
516
|
+
if (!o || o.length === 0) return;
|
|
517
|
+
|
|
518
|
+
var PowerLevel = function(pcmAbsSum, pcmLength) {
|
|
519
|
+
var power = pcmAbsSum / pcmLength || 0;
|
|
520
|
+
var level;
|
|
521
|
+
if (power < 1251) {
|
|
522
|
+
level = Math.round((power / 1250) * 10);
|
|
523
|
+
} else {
|
|
524
|
+
level = Math.round(
|
|
525
|
+
Math.min(
|
|
526
|
+
100,
|
|
527
|
+
Math.max(0, (1 + Math.log(power / 10000) / Math.log(10)) * 100),
|
|
528
|
+
),
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
return level;
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
var size = o.length;
|
|
535
|
+
var pcm = new Int16Array(size);
|
|
536
|
+
var sum = 0;
|
|
537
|
+
for (var j = 0; j < size; j++) {
|
|
538
|
+
var s = Math.max(-1, Math.min(1, o[j]));
|
|
539
|
+
s = s < 0 ? s * 0x8000 : s * 0x7fff;
|
|
540
|
+
pcm[j] = s;
|
|
541
|
+
sum += Math.abs(s);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
var buffers = [];
|
|
545
|
+
buffers.push(pcm);
|
|
546
|
+
|
|
547
|
+
var sizeOld = this.recSize || 0;
|
|
548
|
+
var addSize = pcm.length;
|
|
549
|
+
|
|
550
|
+
var bufferSize = sizeOld + addSize;
|
|
551
|
+
this.recSize = bufferSize;
|
|
552
|
+
|
|
553
|
+
var bufferSampleRate = this.inputSampleRate;
|
|
554
|
+
var powerLevel = PowerLevel(sum, pcm.length);
|
|
555
|
+
var duration = Math.round((bufferSize / bufferSampleRate) * 1000);
|
|
556
|
+
|
|
557
|
+
if (this.showWaveFn) {
|
|
558
|
+
this.showWaveFn(buffers, powerLevel, duration, bufferSampleRate);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// 获取录音状态
|
|
563
|
+
getState() {
|
|
564
|
+
return {
|
|
565
|
+
isRecording: this.isRecording,
|
|
566
|
+
supportsAudioWorklet: this.supportsAudioWorklet,
|
|
567
|
+
bufferSize: this.size,
|
|
568
|
+
duration: this.size > 0 ? Math.round((this.size / this.inputSampleRate) * 1000) : 0,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// 静态方法
|
|
573
|
+
static throwError(message) {
|
|
574
|
+
throw new Error(message);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// 检查浏览器兼容性
|
|
578
|
+
static checkCompatibility() {
|
|
579
|
+
const hasGetUserMedia = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
|
|
580
|
+
const hasAudioContext = !!(window.AudioContext || window.webkitAudioContext);
|
|
581
|
+
const hasAudioWorklet = !!(window.AudioWorkletNode && window.OfflineAudioContext);
|
|
582
|
+
|
|
583
|
+
return {
|
|
584
|
+
hasGetUserMedia,
|
|
585
|
+
hasAudioContext,
|
|
586
|
+
hasAudioWorklet,
|
|
587
|
+
isCompatible: hasGetUserMedia && hasAudioContext,
|
|
588
|
+
recommendedBrowser: hasAudioWorklet ? 'modern' : 'legacy'
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* 在data中的offset位置开始写入str字符串
|
|
595
|
+
*/
|
|
596
|
+
function writeString(data, offset, str) {
|
|
597
|
+
for (var i = 0; i < str.length; i++) {
|
|
598
|
+
data.setUint8(offset + i, str.charCodeAt(i));
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* 合并二进制数据
|
|
604
|
+
*/
|
|
605
|
+
function combineDataView(resultConstructor, ...arrays) {
|
|
606
|
+
let totalLength = 0,
|
|
607
|
+
offset = 0;
|
|
608
|
+
// 统计长度
|
|
609
|
+
for (let arr of arrays) {
|
|
610
|
+
totalLength += arr.length || arr.byteLength;
|
|
611
|
+
}
|
|
612
|
+
// 创建新的存放变量
|
|
613
|
+
let buffer = new ArrayBuffer(totalLength),
|
|
614
|
+
result = new resultConstructor(buffer);
|
|
615
|
+
// 设置数据
|
|
616
|
+
for (let arr of arrays) {
|
|
617
|
+
// dataview合并
|
|
618
|
+
for (let i = 0, len = arr.byteLength; i < len; ++i) {
|
|
619
|
+
result.setInt8(offset, arr.getInt8(i));
|
|
620
|
+
offset += 1;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return result;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// 导出 Recorder 类
|
|
628
|
+
export default Recorder;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
5
|
+
"types": ["vite/client"],
|
|
6
|
+
|
|
7
|
+
/* Linting */
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUnusedLocals": true,
|
|
10
|
+
"noUnusedParameters": true,
|
|
11
|
+
"erasableSyntaxOnly": true,
|
|
12
|
+
"noFallthroughCasesInSwitch": true,
|
|
13
|
+
"noUncheckedSideEffectImports": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
|
16
|
+
}
|