feat: 录音组件
This commit is contained in:
parent
1657a81c75
commit
43f5661be5
@ -54,6 +54,7 @@
|
||||
"pinyin-pro": "^3.26.0",
|
||||
"print-js": "^1.6.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"recorder-core": "^1.3.25011100",
|
||||
"sortablejs": "^1.15.2",
|
||||
"vue": "^3.5.15",
|
||||
"vue-codemirror6": "^1.3.0",
|
||||
|
||||
53
pnpm-lock.yaml
generated
53
pnpm-lock.yaml
generated
@ -74,6 +74,9 @@ importers:
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
recorder-core:
|
||||
specifier: ^1.3.25011100
|
||||
version: 1.3.25011100
|
||||
sortablejs:
|
||||
specifier: ^1.15.2
|
||||
version: 1.15.6
|
||||
@ -1218,9 +1221,6 @@ packages:
|
||||
'@types/svgo@2.6.4':
|
||||
resolution: {integrity: sha512-l4cmyPEckf8moNYHdJ+4wkHvFxjyW6ulm9l4YGaOxeyBWPhBOT0gvni1InpFPdzx1dKf/2s62qGITwxNWnPQng==}
|
||||
|
||||
'@types/web-bluetooth@0.0.16':
|
||||
resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==}
|
||||
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
@ -1420,21 +1420,12 @@ packages:
|
||||
'@vueuse/core@12.8.2':
|
||||
resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==}
|
||||
|
||||
'@vueuse/core@9.13.0':
|
||||
resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==}
|
||||
|
||||
'@vueuse/metadata@12.8.2':
|
||||
resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==}
|
||||
|
||||
'@vueuse/metadata@9.13.0':
|
||||
resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==}
|
||||
|
||||
'@vueuse/shared@12.8.2':
|
||||
resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==}
|
||||
|
||||
'@vueuse/shared@9.13.0':
|
||||
resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==}
|
||||
|
||||
'@wangeditor/basic-modules@1.1.7':
|
||||
resolution: {integrity: sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==}
|
||||
peerDependencies:
|
||||
@ -3940,6 +3931,9 @@ packages:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
|
||||
recorder-core@1.3.25011100:
|
||||
resolution: {integrity: sha512-trXsCH0zurhoizT4Z22C0OsM0SDOW+2OvtgRxeLQFwxoFeqFjDjYZsbZEZUiKMJLhBvamI4K7Ic+qZ2LBo74TA==}
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@ -4821,9 +4815,6 @@ packages:
|
||||
vscode-uri@3.1.0:
|
||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||
|
||||
vue-audio-visual@3.0.11:
|
||||
resolution: {integrity: sha512-toXUXswQqo/oHZuzVnhuZ+m9rM9OU5lgOkckQHGpXizF00XI1gZ1f2KpRrLMKj4e7LC23tZjcgMxDeBJRTenvg==}
|
||||
|
||||
vue-codemirror6@1.3.15:
|
||||
resolution: {integrity: sha512-KjcC1ru686qpsOXwHMWVE7Pv5+u1R1JX+nGd/ovXaLSju/3R1ywMU7HFydmEiFKnsSz9aksDcEy3GNCJMyMFWQ==}
|
||||
engines: {pnpm: '>=10.3.0'}
|
||||
@ -5961,8 +5952,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 22.15.21
|
||||
|
||||
'@types/web-bluetooth@0.0.16': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.32.1(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)':
|
||||
@ -6338,33 +6327,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@vueuse/core@9.13.0(vue@3.5.15(typescript@5.8.3))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.16
|
||||
'@vueuse/metadata': 9.13.0
|
||||
'@vueuse/shared': 9.13.0(vue@3.5.15(typescript@5.8.3))
|
||||
vue-demi: 0.14.10(vue@3.5.15(typescript@5.8.3))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@vueuse/metadata@12.8.2': {}
|
||||
|
||||
'@vueuse/metadata@9.13.0': {}
|
||||
|
||||
'@vueuse/shared@12.8.2(typescript@5.8.3)':
|
||||
dependencies:
|
||||
vue: 3.5.15(typescript@5.8.3)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@vueuse/shared@9.13.0(vue@3.5.15(typescript@5.8.3))':
|
||||
dependencies:
|
||||
vue-demi: 0.14.10(vue@3.5.15(typescript@5.8.3))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2)':
|
||||
dependencies:
|
||||
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2)
|
||||
@ -9010,6 +8980,8 @@ snapshots:
|
||||
dependencies:
|
||||
picomatch: 2.3.1
|
||||
|
||||
recorder-core@1.3.25011100: {}
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
@ -9421,7 +9393,6 @@ snapshots:
|
||||
qrcode: 1.5.4
|
||||
sortablejs: 1.15.6
|
||||
vue: 3.5.15(typescript@5.8.3)
|
||||
vue-audio-visual: 3.0.11(typescript@5.8.3)
|
||||
vue-codemirror6: 1.3.15(@popperjs/core@2.11.8)(core-js@3.42.0)(typescript@5.8.3)(vue@3.5.15(typescript@5.8.3))
|
||||
vue-color-kit: 1.0.6(vue@3.5.15(typescript@5.8.3))
|
||||
vue-i18n: 10.0.0-alpha.3(vue@3.5.15(typescript@5.8.3))
|
||||
@ -10043,14 +10014,6 @@ snapshots:
|
||||
|
||||
vscode-uri@3.1.0: {}
|
||||
|
||||
vue-audio-visual@3.0.11(typescript@5.8.3):
|
||||
dependencies:
|
||||
'@vueuse/core': 9.13.0(vue@3.5.15(typescript@5.8.3))
|
||||
vue: 3.5.15(typescript@5.8.3)
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- typescript
|
||||
|
||||
vue-codemirror6@1.3.15(@popperjs/core@2.11.8)(core-js@3.42.0)(typescript@5.8.3)(vue@3.5.15(typescript@5.8.3)):
|
||||
dependencies:
|
||||
'@codemirror/commands': 6.8.1
|
||||
|
||||
36
src/components.d.ts
vendored
36
src/components.d.ts
vendored
@ -5,23 +5,25 @@
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
export {}
|
||||
|
||||
declare module "vue" {
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AudioVisualizer: (typeof import("./components/audio-visualizer/index.vue"))["default"];
|
||||
BarcodeDraw: (typeof import("./components/barcode-draw/index.vue"))["default"];
|
||||
CodeView: (typeof import("./components/code-view/index.vue"))["default"];
|
||||
ExternalLinkPage: (typeof import("./components/external-link-page/index.vue"))["default"];
|
||||
FillPage: (typeof import("./components/fill-page/index.vue"))["default"];
|
||||
InternalLinkPage: (typeof import("./components/internal-link-page/index.vue"))["default"];
|
||||
LangProvider: (typeof import("./components/lang-provider/index.vue"))["default"];
|
||||
MainTransition: (typeof import("./components/main-transition/index.vue"))["default"];
|
||||
PinyinPro: (typeof import("./components/pinyin-pro/index.vue"))["default"];
|
||||
QrcodeDraw: (typeof import("./components/qrcode-draw/index.vue"))["default"];
|
||||
RouterLink: (typeof import("vue-router"))["RouterLink"];
|
||||
RouterView: (typeof import("vue-router"))["RouterView"];
|
||||
SelectIcon: (typeof import("./components/select-icon/index.vue"))["default"];
|
||||
SvgAndIcon: (typeof import("./components/svg-and-icon/index.vue"))["default"];
|
||||
SvgIcon: (typeof import("./components/svg-icon/index.vue"))["default"];
|
||||
VerifyCode: (typeof import("./components/verify-code/index.vue"))["default"];
|
||||
AudioVisualizer: typeof import('./components/audio-visualizer/index.vue')['default']
|
||||
BarcodeDraw: typeof import('./components/barcode-draw/index.vue')['default']
|
||||
CodeView: typeof import('./components/code-view/index.vue')['default']
|
||||
ExternalLinkPage: typeof import('./components/external-link-page/index.vue')['default']
|
||||
FillPage: typeof import('./components/fill-page/index.vue')['default']
|
||||
InternalLinkPage: typeof import('./components/internal-link-page/index.vue')['default']
|
||||
LangProvider: typeof import('./components/lang-provider/index.vue')['default']
|
||||
MainTransition: typeof import('./components/main-transition/index.vue')['default']
|
||||
PinyinPro: typeof import('./components/pinyin-pro/index.vue')['default']
|
||||
QrcodeDraw: typeof import('./components/qrcode-draw/index.vue')['default']
|
||||
Recorder: (typeof import("./components/recorder/index.vue"))["default"]
|
||||
RecorderPcm: typeof import('./components/recorder-pcm/index.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SelectIcon: typeof import('./components/select-icon/index.vue')['default']
|
||||
SvgAndIcon: typeof import('./components/svg-and-icon/index.vue')['default']
|
||||
SvgIcon: typeof import('./components/svg-icon/index.vue')['default']
|
||||
VerifyCode: typeof import('./components/verify-code/index.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
115
src/components/recorder-pcm/index.vue
Normal file
115
src/components/recorder-pcm/index.vue
Normal file
@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div style="width: 100%; height: 100%" ref="recwave"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
//必须引入的核心
|
||||
import Recorder from "recorder-core";
|
||||
//引入pcm格式支持文件
|
||||
import "recorder-core/src/engine/pcm";
|
||||
//可选的插件支持项,这个是波形可视化插件
|
||||
import "recorder-core/src/extensions/waveview";
|
||||
//ts import 提示:npm包内已自带了.d.ts声明文件(不过是any类型)
|
||||
|
||||
const emit = defineEmits(["change"]);
|
||||
|
||||
let wave: any; // 用于绘制波形
|
||||
const recwave = ref(null); // 绑定到dom元素上
|
||||
let rec: any; // Recorder实例
|
||||
let send_chunk: any; // 上次分割点数据
|
||||
let testSampleRate = 16000; // 采样率
|
||||
|
||||
//重置环境,每次开始录音时必须先调用此方法,清理环境
|
||||
const RealTimeSendReset = () => {
|
||||
send_chunk = null;
|
||||
};
|
||||
|
||||
// 打开录音
|
||||
const recOpen = () => {
|
||||
RealTimeSendReset();
|
||||
// 创建录音对象
|
||||
rec = Recorder({
|
||||
type: "unknown", //这里特意使用unknown格式,方便清理内存
|
||||
onProcess: (buffers: any, powerLevel: any, _: any, bufferSampleRate: any) => {
|
||||
// 所有的pcm数据queue,缓存采样率,是否结束
|
||||
RealTimeSendTry(buffers, bufferSampleRate, false);
|
||||
if (wave) {
|
||||
wave.input(buffers[buffers.length - 1], powerLevel, bufferSampleRate);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!rec) {
|
||||
alert("当前浏览器不支持录音功能!");
|
||||
return;
|
||||
}
|
||||
// 打开录音,获得权限
|
||||
rec.open(
|
||||
() => {
|
||||
if (recwave.value) {
|
||||
// 创建音频可视化图形绘制对象
|
||||
wave = Recorder.WaveView({ elem: recwave.value });
|
||||
}
|
||||
},
|
||||
(msg: any, isUserNotAllow: any) => {
|
||||
// 用户拒绝了录音权限,或者浏览器不支持录音
|
||||
console.log((isUserNotAllow ? "UserNotAllow," : "") + "无法录音:" + msg);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const RealTimeSendTry = (buffers: any, bufferSampleRate: any, isClose: any) => {
|
||||
//提取出新的pcm数据
|
||||
let pcm = new Int16Array(0);
|
||||
if (buffers.length > 0) {
|
||||
//【关键代码】借用SampleData函数进行数据的连续处理,采样率转换是顺带的,得到新的pcm数据
|
||||
// send_chunk为上次分割点
|
||||
let chunk = Recorder.SampleData(buffers, bufferSampleRate, testSampleRate, send_chunk);
|
||||
send_chunk = chunk; // 保存本次分割点,用于下次使用
|
||||
pcm = chunk.data; //此时的pcm就是原始的音频16位pcm数据(小端LE),直接保存即为16位pcm文件、加个wav头即为wav文件、丢给mp3编码器转一下码即为mp3文件
|
||||
}
|
||||
|
||||
//没有指定固定的帧大小,直接把pcm发送出去即可
|
||||
TransferUpload(pcm, isClose);
|
||||
return;
|
||||
};
|
||||
|
||||
//=====数据传输函数==========
|
||||
const TransferUpload = (pcmFrame: any, isClose: any) => {
|
||||
if (isClose && pcmFrame.length == 0) {
|
||||
// 这里就是数据发送完成,录音结束的位置,可以停止ws了
|
||||
emit("change", { arrayBuffer: pcmFrame.buffer, close: true });
|
||||
// ws.send(arrayBuffer,true)
|
||||
return; //如果不需要处理最后一帧数据,直接return不做任何处理
|
||||
}
|
||||
// 二进制数据
|
||||
let arrayBuffer = pcmFrame.buffer;
|
||||
|
||||
emit("change", { arrayBuffer, close: false });
|
||||
//可以实现
|
||||
//WebSocket send(arrayBuffer) ...
|
||||
//WebRTC send(arrayBuffer) ...
|
||||
//XMLHttpRequest send(arrayBuffer) ...
|
||||
};
|
||||
|
||||
// 开始录音
|
||||
const recStart = () => {
|
||||
if (!rec) return console.error("未打开录音");
|
||||
rec.start();
|
||||
// 已开始录音
|
||||
};
|
||||
// 结束录音
|
||||
const recStop = () => {
|
||||
if (!rec) return console.error("未打开录音");
|
||||
rec.close(); // 关闭录音,释放录音资源
|
||||
rec = null;
|
||||
RealTimeSendTry([], 0, true); //最后一次发送
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
recOpen,
|
||||
recStart,
|
||||
recStop
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@ -105,6 +105,7 @@ export default {
|
||||
["test-common-route"]: "test common route",
|
||||
["test-dynamic-route"]: "test dynamic route",
|
||||
["audio"]: "audio",
|
||||
["recorder"]: "recorder",
|
||||
["test"]: "test"
|
||||
}
|
||||
};
|
||||
|
||||
@ -105,6 +105,7 @@ export default {
|
||||
["test-common-route"]: "测试普通路由",
|
||||
["test-dynamic-route"]: "测试动态路由",
|
||||
["audio"]: "音频",
|
||||
["recorder"]: "录音",
|
||||
["test"]: "测试"
|
||||
}
|
||||
};
|
||||
|
||||
@ -632,6 +632,27 @@ export const systemMenu = [
|
||||
},
|
||||
children: null
|
||||
},
|
||||
{
|
||||
id: "0613",
|
||||
parentId: "06",
|
||||
path: "/component/recorder",
|
||||
name: "recorder",
|
||||
component: "component/recorder/recorder",
|
||||
meta: {
|
||||
title: "recorder",
|
||||
hide: false,
|
||||
disable: false,
|
||||
keepAlive: true,
|
||||
affix: false,
|
||||
link: "",
|
||||
iframe: false,
|
||||
roles: ["admin"],
|
||||
icon: "icon-menu",
|
||||
sort: 13,
|
||||
type: 2
|
||||
},
|
||||
children: null
|
||||
},
|
||||
{
|
||||
id: "07",
|
||||
parentId: "0",
|
||||
|
||||
@ -101,7 +101,7 @@ const onAudio = (blob: Blob) => {
|
||||
<div class="snow-inner">
|
||||
<a-alert>本地录音需要https或localhost安全环境,是否为安全环境:{{ isSecureEnvironment() }}</a-alert>
|
||||
<a-space direction="vertical" fill>
|
||||
<div :span="18" class="audio-box">
|
||||
<div class="audio-box">
|
||||
<AudioVisualizer ref="AudioVisualizerRef" v-model="config" @audio="onAudio" />
|
||||
</div>
|
||||
<a-space direction="vertical" fill>
|
||||
|
||||
162
src/views/component/recorder/recorder.vue
Normal file
162
src/views/component/recorder/recorder.vue
Normal file
@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div class="snow-page">
|
||||
<div class="snow-inner">
|
||||
<a-space direction="vertical" fill>
|
||||
<a-alert>本地录音需要https或localhost安全环境,是否为安全环境:{{ isSecureEnvironment() }}</a-alert>
|
||||
<a-alert type="success"
|
||||
>基于<a-link href="https://github.com/xiangyuecn/Recorder" target="_blank">Recorder.js</a-link
|
||||
>,采用PCM实时帧回调的方式,用于流式语音传输</a-alert
|
||||
>
|
||||
<a-space direction="vertical" fill>
|
||||
<!-- 波形绘制区域 -->
|
||||
<div class="waveview-box">
|
||||
<recorder-pcm ref="RecorderPCM" @change="onChange" />
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="recOpen">打开录音权限</a-button>
|
||||
<a-button type="primary" @click="recStart">开始录音</a-button>
|
||||
<a-button @click="recStop">结束录音</a-button>
|
||||
<a-button @click="audioList.length = 0">清空列表</a-button>
|
||||
</a-space>
|
||||
</a-space>
|
||||
<audio ref="audioElement" controls v-if="audioList.length"></audio>
|
||||
<a-list v-if="audioList.length">
|
||||
<template #header>音频帧列表</template>
|
||||
<a-list-item v-for="(item, index) in audioList" :key="index">
|
||||
<a-space>
|
||||
<span v-if="!item.close">PCM音频实时帧 - {{ index }}</span>
|
||||
<span v-else>PCM完整音频</span>
|
||||
<a-button-group>
|
||||
<a-button @click="playAudio(item)">播放</a-button>
|
||||
</a-button-group>
|
||||
</a-space>
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isSecureEnvironment } from "@/utils/index";
|
||||
|
||||
const RecorderPCM = ref();
|
||||
|
||||
const recOpen = () => {
|
||||
audioList.value.length = 0;
|
||||
RecorderPCM.value.recOpen();
|
||||
};
|
||||
|
||||
const recStart = () => {
|
||||
RecorderPCM.value.recStart();
|
||||
};
|
||||
|
||||
const recStop = () => {
|
||||
RecorderPCM.value.recStop();
|
||||
};
|
||||
|
||||
interface AudioInfo {
|
||||
arrayBuffer: ArrayBuffer;
|
||||
close: boolean;
|
||||
}
|
||||
|
||||
// 音频帧列表
|
||||
const audioList = ref<AudioInfo[]>([]);
|
||||
|
||||
// 音频帧实时回调
|
||||
const onChange = async (e: AudioInfo) => {
|
||||
if (!e.close) {
|
||||
audioList.value.push(e);
|
||||
} else {
|
||||
// 合并音频帧列表
|
||||
let complete = mergePcmChunks(audioList.value.map(item => item.arrayBuffer));
|
||||
audioList.value.push({ arrayBuffer: complete, close: true });
|
||||
}
|
||||
};
|
||||
|
||||
// 合并多个PCM分片
|
||||
const mergePcmChunks = (chunks: ArrayBuffer[]) => {
|
||||
// 计算总长度
|
||||
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
||||
|
||||
// 创建新的ArrayBuffer
|
||||
const mergedBuffer = new ArrayBuffer(totalLength);
|
||||
const mergedView = new Uint8Array(mergedBuffer);
|
||||
|
||||
// 按顺序复制每个分片
|
||||
let offset = 0;
|
||||
chunks.forEach(chunk => {
|
||||
mergedView.set(new Uint8Array(chunk), offset);
|
||||
offset += chunk.byteLength;
|
||||
});
|
||||
|
||||
return mergedBuffer;
|
||||
};
|
||||
|
||||
const audioElement = ref<any>();
|
||||
const playAudio = (e: AudioInfo) => {
|
||||
const wavBuffer = pcmToWav(e.arrayBuffer);
|
||||
const audioBlob = new Blob([wavBuffer], { type: "audio/wav" });
|
||||
|
||||
// 为Blob创建URL对象
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
|
||||
// 设置audio元素的src
|
||||
audioElement.value.src = audioUrl;
|
||||
|
||||
// 开始播放
|
||||
audioElement.value.play();
|
||||
|
||||
// 播放结束后释放URL对象
|
||||
audioElement.value.onended = function () {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
};
|
||||
};
|
||||
|
||||
// PCM转WAV函数
|
||||
const pcmToWav = (pcmArrayBuffer: ArrayBuffer, sampleRate = 16000, numChannels = 1, bitDepth = 16) => {
|
||||
const buffer = new ArrayBuffer(44 + pcmArrayBuffer.byteLength);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
// 写入WAV文件头
|
||||
writeString(view, 0, "RIFF");
|
||||
view.setUint32(4, 36 + pcmArrayBuffer.byteLength, true);
|
||||
writeString(view, 8, "WAVE");
|
||||
|
||||
// 写入fmt子块
|
||||
writeString(view, 12, "fmt ");
|
||||
view.setUint32(16, 16, true); // 子块大小
|
||||
view.setUint16(20, 1, true); // 音频格式(1表示PCM)
|
||||
view.setUint16(22, numChannels, true); // 声道数
|
||||
view.setUint32(24, sampleRate, true); // 采样率
|
||||
view.setUint32(28, sampleRate * numChannels * (bitDepth / 8), true); // 字节率
|
||||
view.setUint16(32, numChannels * (bitDepth / 8), true); // 块对齐
|
||||
view.setUint16(34, bitDepth, true); // 位深度
|
||||
|
||||
// 写入data子块
|
||||
writeString(view, 36, "data");
|
||||
view.setUint32(40, pcmArrayBuffer.byteLength, true);
|
||||
|
||||
// 写入PCM数据
|
||||
const pcmData = new Uint8Array(pcmArrayBuffer);
|
||||
const resultData = new Uint8Array(buffer);
|
||||
resultData.set(pcmData, 44);
|
||||
|
||||
return buffer;
|
||||
};
|
||||
|
||||
const writeString = (view: DataView, offset: number, string: string) => {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.waveview-box {
|
||||
display: inline-block;
|
||||
width: 400px;
|
||||
height: 100px;
|
||||
border: 1px solid $color-border-2;
|
||||
}
|
||||
</style>
|
||||
Loading…
x
Reference in New Issue
Block a user