123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353 |
- <script setup>
- import { ref, onMounted, onBeforeUnmount } from "vue";
- import { KeyboardVoiceOutlined, KeyboardAltOutlined } from "@vicons/material";
- import axios from "axios";
- import { Icon } from "@vicons/utils";
- import Recorder from "recorder-core";
- import "recorder-core/src/engine/wav";
- import "recorder-core/src/extensions/frequency.histogram.view";
- import "recorder-core/src/extensions/lib.fft";
- const emit = defineEmits(["startRecord", "stopRecord"]);
- const isTalking = ref(false);
- const sessionId = ref("");
- const userTalk = ref("");
- let rec;
- let testSampleRate = ref(16000);
- let testBitRate = ref(16);
- let SendInterval = 300; // 控制实时传输间隔
- let realTimeSendTryType,
- realTimeSendTryEncBusy,
- realTimeSendTryTime = 0,
- realTimeSendTryNumber,
- transferUploadNumberMax,
- realTimeSendTryChunk,
- voicePkgSeq = 0,
- wave = null;
- const RealTimeSendTryReset = (type) => {
- realTimeSendTryType = type;
- realTimeSendTryTime = 0;
- realTimeSendTryEncBusy = 0;
- realTimeSendTryNumber = 0;
- transferUploadNumberMax = 0;
- realTimeSendTryChunk = null;
- };
- const RealTimeSendTry = (buffers, bufferSampleRate, isClose) => {
- const t1 = Date.now();
- if (realTimeSendTryTime === 0) {
- realTimeSendTryTime = t1;
- realTimeSendTryEncBusy = 0;
- realTimeSendTryNumber = 0;
- transferUploadNumberMax = 0;
- realTimeSendTryChunk = null;
- }
- if (!isClose && t1 - realTimeSendTryTime < SendInterval) {
- return; // 控制缓冲达到指定间隔才进行传输
- }
- realTimeSendTryTime = t1;
- const number = ++realTimeSendTryNumber;
- let pcm = [],
- pcmSampleRate = 0;
- if (buffers.length > 0) {
- // 借用SampleData函数进行数据的连续处理,采样率转换是顺带的,得到新的pcm数据
- const chunk = Recorder.SampleData(
- buffers,
- bufferSampleRate,
- testSampleRate.value,
- realTimeSendTryChunk,
- { frameType: isClose ? "" : realTimeSendTryType }
- );
- for (
- let i = realTimeSendTryChunk ? realTimeSendTryChunk.index : 0;
- i < chunk.index;
- i++
- ) {
- buffers[i] = null; // 清理已处理完的缓冲数据
- }
- realTimeSendTryChunk = chunk;
- pcm = chunk.data;
- pcmSampleRate = chunk.sampleRate;
- }
- // 如果没有新数据,或者结束时数据量太小,不能进行转码
- if (pcm.length === 0 || (isClose && pcm.length < 2000)) {
- TransferUpload(number, null, 0, null, isClose);
- return;
- }
- // 实时编码队列阻塞处理
- if (!isClose) {
- if (realTimeSendTryEncBusy >= 2) {
- console.log("编码队列阻塞,已丢弃一帧", 1);
- return;
- }
- }
- realTimeSendTryEncBusy++;
- const encStartTime = Date.now();
- const recMock = Recorder({
- type: realTimeSendTryType,
- sampleRate: testSampleRate.value, // 采样率
- bitRate: testBitRate.value, // 比特率
- });
- recMock.mock(pcm, pcmSampleRate);
- recMock.stop(
- (blob, duration) => {
- if (realTimeSendTryEncBusy) realTimeSendTryEncBusy--;
- blob.encTime = Date.now() - encStartTime;
- TransferUpload(number, blob, duration, recMock, isClose);
- },
- (msg) => {
- if (realTimeSendTryEncBusy) realTimeSendTryEncBusy--;
- console.log("不应该出现的错误:" + msg, 1);
- }
- );
- };
- const TransferUpload = (number, blobOrNull, duration, blobRec, isClose) => {
- transferUploadNumberMax = Math.max(transferUploadNumberMax, number);
- if (blobOrNull) {
- let blob = blobOrNull;
- let encTime = blob.encTime;
- // 发送数据的方式一:Base64文本发送
- let reader = new FileReader();
- reader.onloadend = () => {
- let base64 = (/.+;\s*base64\s*,\s*(.+)$/i.exec(reader.result) || [])[1];
- // 可以实现 WebSocket send(base64), WebRTC send(base64), XMLHttpRequest send(base64)
- asrPost("SESSION_IN", base64);
- };
- reader.readAsDataURL(blob);
- }
- if (isClose) {
- console.log(
- "No." +
- (number < 100 ? ("000" + number).substr(-3) : number) +
- ":已停止传输"
- );
- }
- };
- const makeRandomString = (len) => {
- len = len || 32;
- let $chars =
- "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678"; /****默认去掉了容易混淆的字符oOLl,9gq,Vv,Uu,I1****/
- let maxPos = $chars.length;
- let pwd = "";
- for (let i = 0; i < len; i++) {
- pwd += $chars.charAt(Math.floor(Math.random() * maxPos));
- }
- return pwd + new Date().getTime();
- };
- // 语音识别
- const asrPost = async (eventCodeType = "SESSION_IN", base64) => {
- const res = await axios.post(
- `/openapi-stg/ai/voice/asr/v1`,
- {
- sessionId: sessionId.value,
- eventCodeType,
- voicePkgSeq: ++voicePkgSeq,
- audio: base64,
- format: 16000,
- encoding: "WAV",
- },
- {
- headers: {
- "X-Ai-Asr-Appid": "2b1317fb5b284b308dc90a6fdeae6c4e",
- },
- }
- );
- console.log(res.data.body.asrResultText);
- userTalk.value = userTalk.value + res.data.body.asrResultText;
- };
- onMounted(() => {
- sessionId.value = makeRandomString(5);
- if (rec) {
- rec.close();
- }
- rec = Recorder({
- type: "unknown",
- onProcess: (buffers, powerLevel, bufferDuration, bufferSampleRate) => {
- RealTimeSendTry(buffers, bufferSampleRate, false);
- wave &&
- buffers[buffers.length - 1] &&
- wave.input(buffers[buffers.length - 1], powerLevel, bufferSampleRate);
- },
- });
- const t = setTimeout(() => {
- console.log("无法录音:权限请求被忽略(超时假装手动点击了确认对话框)", 1);
- }, 8000);
- rec.open(
- () => {
- if (Recorder.FrequencyHistogramView) {
- wave = Recorder.FrequencyHistogramView({
- elem: ".recwave",
- lineCount: 10,
- position: 0,
- minHeight: 1,
- fallDuration: 400,
- stripeEnable: false,
- mirrorEnable: true,
- linear: [0, "#fff", 1, "#fff"],
- });
- }
- clearTimeout(t);
- },
- (msg, isUserNotAllow) => {
- clearTimeout(t);
- console.log(
- (isUserNotAllow ? "UserNotAllow," : "") + "无法录音:" + msg,
- 1
- );
- }
- );
- //console.log(document.querySelector(".recwave"));
- //const div = document.createElement("div");
- // const div = document.querySelector(".wave");
- // div.innerHTML =
- // '<div style="height:100px;width:100%;" class="recwave"></div>';
- //document.body.prepend(div);
- });
- onBeforeUnmount(() => {
- if (rec) rec.close();
- });
- const startRecording = async (e) => {
- e.preventDefault();
- isTalking.value = true;
- userTalk.value = "";
- asrPost("SESSION_BEGIN");
- rec.start();
- RealTimeSendTryReset("wav");
- emit("startRecord");
- };
- const stopRecording = async () => {
- isTalking.value = false;
- //等待500ms后,在执行以下代码
- await new Promise((resolve) => setTimeout(resolve, 1000));
- rec.stop();
- RealTimeSendTry([], 0, true); // 最后一次发送
- await asrPost("SESSION_END");
- voicePkgSeq = 0;
- emit("stopRecord", userTalk.value);
- };
- </script>
- <template>
- <div
- :class="`bottom-btn ${isTalking ? 'touch-start' : ''}`"
- @touchstart="startRecording"
- @touchend="stopRecording"
- >
- <div class="btn">
- <div class="text">按住说话</div>
- <Icon class="icon" color="white"><KeyboardAltOutlined /></Icon>
- </div>
- <div class="talk-item">
- <div class="wave">
- <div class="recwave" style="height: 100%; width: 100%"></div>
- </div>
- <Icon class="icon" color="#1989fa"><KeyboardVoiceOutlined /></Icon>
- <div>松手发送</div>
- </div>
- </div>
- </template>
- <style scoped>
- .bottom-btn {
- position: fixed;
- bottom: 0;
- right: 0;
- width: 120vw;
- left: -10vw;
- display: flex;
- justify-content: center;
- height: 80px;
- background: rgba(255, 255, 255, 0);
- transition: height 0.1s, background 0.1s, border-radius 0.1s;
- padding-bottom: env(safe-area-inset-bottom);
- }
- .bottom-btn.touch-start {
- background: rgba(255, 255, 255, 0.8);
- border-radius: 50% 50% 0 0;
- height: 120px;
- }
- .bottom-btn.touch-start .btn {
- display: none;
- }
- .bottom-btn.touch-start .talk-item {
- visibility: visible;
- }
- .btn {
- width: 90vw;
- height: 40px;
- background: rgba(150, 150, 150, 0.5);
- color: white;
- border-radius: 4px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- position: absolute;
- bottom: calc(20px + env(safe-area-inset-bottom));
- }
- .btn .text {
- flex-grow: 1;
- text-align: center;
- }
- .btn .icon {
- margin-right: 10px;
- }
- .talk-item {
- position: absolute;
- bottom: calc(40px + env(safe-area-inset-bottom));
- visibility: hidden;
- text-align: center;
- color: #1989fa;
- }
- .talk-item .wave {
- position: absolute;
- top: -100px;
- left: 50%;
- margin-left: -50px;
- width: 100px;
- height: 50px;
- background: #1989fa;
- border-radius: 8px;
- }
- .talk-item .wave::after {
- content: "";
- position: absolute;
- bottom: -8px;
- left: 50%;
- transform: translateX(-50%);
- width: 0;
- height: 0;
- border-left: 8px solid transparent;
- border-right: 8px solid transparent;
- border-top: 8px solid rgb(25, 137, 250);
- }
- .talk-item .icon {
- font-size: 24px;
- }
- </style>
|