BottomArea.vue 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. <script setup>
  2. import { ref, onMounted, onBeforeUnmount } from "vue";
  3. import { KeyboardVoiceOutlined, KeyboardAltOutlined } from "@vicons/material";
  4. import axios from "axios";
  5. import { Icon } from "@vicons/utils";
  6. import Recorder from "recorder-core";
  7. import "recorder-core/src/engine/wav";
  8. import "recorder-core/src/extensions/frequency.histogram.view";
  9. import "recorder-core/src/extensions/lib.fft";
  10. const emit = defineEmits(["startRecord", "stopRecord"]);
  11. const isTalking = ref(false);
  12. const sessionId = ref("");
  13. const userTalk = ref("");
  14. let rec;
  15. let testSampleRate = ref(16000);
  16. let testBitRate = ref(16);
  17. let SendInterval = 300; // 控制实时传输间隔
  18. let realTimeSendTryType,
  19. realTimeSendTryEncBusy,
  20. realTimeSendTryTime = 0,
  21. realTimeSendTryNumber,
  22. transferUploadNumberMax,
  23. realTimeSendTryChunk,
  24. voicePkgSeq = 0,
  25. wave = null;
  26. const RealTimeSendTryReset = (type) => {
  27. realTimeSendTryType = type;
  28. realTimeSendTryTime = 0;
  29. realTimeSendTryEncBusy = 0;
  30. realTimeSendTryNumber = 0;
  31. transferUploadNumberMax = 0;
  32. realTimeSendTryChunk = null;
  33. };
  34. const RealTimeSendTry = (buffers, bufferSampleRate, isClose) => {
  35. const t1 = Date.now();
  36. if (realTimeSendTryTime === 0) {
  37. realTimeSendTryTime = t1;
  38. realTimeSendTryEncBusy = 0;
  39. realTimeSendTryNumber = 0;
  40. transferUploadNumberMax = 0;
  41. realTimeSendTryChunk = null;
  42. }
  43. if (!isClose && t1 - realTimeSendTryTime < SendInterval) {
  44. return; // 控制缓冲达到指定间隔才进行传输
  45. }
  46. realTimeSendTryTime = t1;
  47. const number = ++realTimeSendTryNumber;
  48. let pcm = [],
  49. pcmSampleRate = 0;
  50. if (buffers.length > 0) {
  51. // 借用SampleData函数进行数据的连续处理,采样率转换是顺带的,得到新的pcm数据
  52. const chunk = Recorder.SampleData(
  53. buffers,
  54. bufferSampleRate,
  55. testSampleRate.value,
  56. realTimeSendTryChunk,
  57. { frameType: isClose ? "" : realTimeSendTryType }
  58. );
  59. for (
  60. let i = realTimeSendTryChunk ? realTimeSendTryChunk.index : 0;
  61. i < chunk.index;
  62. i++
  63. ) {
  64. buffers[i] = null; // 清理已处理完的缓冲数据
  65. }
  66. realTimeSendTryChunk = chunk;
  67. pcm = chunk.data;
  68. pcmSampleRate = chunk.sampleRate;
  69. }
  70. // 如果没有新数据,或者结束时数据量太小,不能进行转码
  71. if (pcm.length === 0 || (isClose && pcm.length < 2000)) {
  72. TransferUpload(number, null, 0, null, isClose);
  73. return;
  74. }
  75. // 实时编码队列阻塞处理
  76. if (!isClose) {
  77. if (realTimeSendTryEncBusy >= 2) {
  78. console.log("编码队列阻塞,已丢弃一帧", 1);
  79. return;
  80. }
  81. }
  82. realTimeSendTryEncBusy++;
  83. const encStartTime = Date.now();
  84. const recMock = Recorder({
  85. type: realTimeSendTryType,
  86. sampleRate: testSampleRate.value, // 采样率
  87. bitRate: testBitRate.value, // 比特率
  88. });
  89. recMock.mock(pcm, pcmSampleRate);
  90. recMock.stop(
  91. (blob, duration) => {
  92. if (realTimeSendTryEncBusy) realTimeSendTryEncBusy--;
  93. blob.encTime = Date.now() - encStartTime;
  94. TransferUpload(number, blob, duration, recMock, isClose);
  95. },
  96. (msg) => {
  97. if (realTimeSendTryEncBusy) realTimeSendTryEncBusy--;
  98. console.log("不应该出现的错误:" + msg, 1);
  99. }
  100. );
  101. };
  102. const TransferUpload = (number, blobOrNull, duration, blobRec, isClose) => {
  103. transferUploadNumberMax = Math.max(transferUploadNumberMax, number);
  104. if (blobOrNull) {
  105. let blob = blobOrNull;
  106. let encTime = blob.encTime;
  107. // 发送数据的方式一:Base64文本发送
  108. let reader = new FileReader();
  109. reader.onloadend = () => {
  110. let base64 = (/.+;\s*base64\s*,\s*(.+)$/i.exec(reader.result) || [])[1];
  111. // 可以实现 WebSocket send(base64), WebRTC send(base64), XMLHttpRequest send(base64)
  112. asrPost("SESSION_IN", base64);
  113. };
  114. reader.readAsDataURL(blob);
  115. }
  116. if (isClose) {
  117. console.log(
  118. "No." +
  119. (number < 100 ? ("000" + number).substr(-3) : number) +
  120. ":已停止传输"
  121. );
  122. }
  123. };
  124. const makeRandomString = (len) => {
  125. len = len || 32;
  126. let $chars =
  127. "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678"; /****默认去掉了容易混淆的字符oOLl,9gq,Vv,Uu,I1****/
  128. let maxPos = $chars.length;
  129. let pwd = "";
  130. for (let i = 0; i < len; i++) {
  131. pwd += $chars.charAt(Math.floor(Math.random() * maxPos));
  132. }
  133. return pwd + new Date().getTime();
  134. };
  135. // 语音识别
  136. const asrPost = async (eventCodeType = "SESSION_IN", base64) => {
  137. const res = await axios.post(
  138. `/openapi-stg/ai/voice/asr/v1`,
  139. {
  140. sessionId: sessionId.value,
  141. eventCodeType,
  142. voicePkgSeq: ++voicePkgSeq,
  143. audio: base64,
  144. format: 16000,
  145. encoding: "WAV",
  146. },
  147. {
  148. headers: {
  149. "X-Ai-Asr-Appid": "2b1317fb5b284b308dc90a6fdeae6c4e",
  150. },
  151. }
  152. );
  153. console.log(res.data.body.asrResultText);
  154. userTalk.value = userTalk.value + res.data.body.asrResultText;
  155. };
  156. onMounted(() => {
  157. sessionId.value = makeRandomString(5);
  158. if (rec) {
  159. rec.close();
  160. }
  161. rec = Recorder({
  162. type: "unknown",
  163. onProcess: (buffers, powerLevel, bufferDuration, bufferSampleRate) => {
  164. RealTimeSendTry(buffers, bufferSampleRate, false);
  165. wave &&
  166. buffers[buffers.length - 1] &&
  167. wave.input(buffers[buffers.length - 1], powerLevel, bufferSampleRate);
  168. },
  169. });
  170. const t = setTimeout(() => {
  171. console.log("无法录音:权限请求被忽略(超时假装手动点击了确认对话框)", 1);
  172. }, 8000);
  173. rec.open(
  174. () => {
  175. if (Recorder.FrequencyHistogramView) {
  176. wave = Recorder.FrequencyHistogramView({
  177. elem: ".recwave",
  178. lineCount: 10,
  179. position: 0,
  180. minHeight: 1,
  181. fallDuration: 400,
  182. stripeEnable: false,
  183. mirrorEnable: true,
  184. linear: [0, "#fff", 1, "#fff"],
  185. });
  186. }
  187. clearTimeout(t);
  188. },
  189. (msg, isUserNotAllow) => {
  190. clearTimeout(t);
  191. console.log(
  192. (isUserNotAllow ? "UserNotAllow," : "") + "无法录音:" + msg,
  193. 1
  194. );
  195. }
  196. );
  197. //console.log(document.querySelector(".recwave"));
  198. //const div = document.createElement("div");
  199. // const div = document.querySelector(".wave");
  200. // div.innerHTML =
  201. // '<div style="height:100px;width:100%;" class="recwave"></div>';
  202. //document.body.prepend(div);
  203. });
  204. onBeforeUnmount(() => {
  205. if (rec) rec.close();
  206. });
  207. const startRecording = async (e) => {
  208. e.preventDefault();
  209. isTalking.value = true;
  210. userTalk.value = "";
  211. asrPost("SESSION_BEGIN");
  212. rec.start();
  213. RealTimeSendTryReset("wav");
  214. emit("startRecord");
  215. };
  216. const stopRecording = async () => {
  217. isTalking.value = false;
  218. //等待500ms后,在执行以下代码
  219. await new Promise((resolve) => setTimeout(resolve, 1000));
  220. rec.stop();
  221. RealTimeSendTry([], 0, true); // 最后一次发送
  222. await asrPost("SESSION_END");
  223. voicePkgSeq = 0;
  224. emit("stopRecord", userTalk.value);
  225. };
  226. </script>
  227. <template>
  228. <div
  229. :class="`bottom-btn ${isTalking ? 'touch-start' : ''}`"
  230. @touchstart="startRecording"
  231. @touchend="stopRecording"
  232. >
  233. <div class="btn">
  234. <div class="text">按住说话</div>
  235. <Icon class="icon" color="white"><KeyboardAltOutlined /></Icon>
  236. </div>
  237. <div class="talk-item">
  238. <div class="wave">
  239. <div class="recwave" style="height: 100%; width: 100%"></div>
  240. </div>
  241. <Icon class="icon" color="#1989fa"><KeyboardVoiceOutlined /></Icon>
  242. <div>松手发送</div>
  243. </div>
  244. </div>
  245. </template>
  246. <style scoped>
  247. .bottom-btn {
  248. position: fixed;
  249. bottom: 0;
  250. right: 0;
  251. width: 120vw;
  252. left: -10vw;
  253. display: flex;
  254. justify-content: center;
  255. height: 80px;
  256. background: rgba(255, 255, 255, 0);
  257. transition: height 0.1s, background 0.1s, border-radius 0.1s;
  258. padding-bottom: env(safe-area-inset-bottom);
  259. }
  260. .bottom-btn.touch-start {
  261. background: rgba(255, 255, 255, 0.8);
  262. border-radius: 50% 50% 0 0;
  263. height: 120px;
  264. }
  265. .bottom-btn.touch-start .btn {
  266. display: none;
  267. }
  268. .bottom-btn.touch-start .talk-item {
  269. visibility: visible;
  270. }
  271. .btn {
  272. width: 90vw;
  273. height: 40px;
  274. background: rgba(150, 150, 150, 0.5);
  275. color: white;
  276. border-radius: 4px;
  277. display: flex;
  278. align-items: center;
  279. justify-content: space-between;
  280. position: absolute;
  281. bottom: calc(20px + env(safe-area-inset-bottom));
  282. }
  283. .btn .text {
  284. flex-grow: 1;
  285. text-align: center;
  286. }
  287. .btn .icon {
  288. margin-right: 10px;
  289. }
  290. .talk-item {
  291. position: absolute;
  292. bottom: calc(40px + env(safe-area-inset-bottom));
  293. visibility: hidden;
  294. text-align: center;
  295. color: #1989fa;
  296. }
  297. .talk-item .wave {
  298. position: absolute;
  299. top: -100px;
  300. left: 50%;
  301. margin-left: -50px;
  302. width: 100px;
  303. height: 50px;
  304. background: #1989fa;
  305. border-radius: 8px;
  306. }
  307. .talk-item .wave::after {
  308. content: "";
  309. position: absolute;
  310. bottom: -8px;
  311. left: 50%;
  312. transform: translateX(-50%);
  313. width: 0;
  314. height: 0;
  315. border-left: 8px solid transparent;
  316. border-right: 8px solid transparent;
  317. border-top: 8px solid rgb(25, 137, 250);
  318. }
  319. .talk-item .icon {
  320. font-size: 24px;
  321. }
  322. </style>