Browse Source

feat: 语音消息

qinyuyue 2 months ago
parent
commit
23d64aeca2

+ 1 - 1
nuxt.config.ts

@@ -90,5 +90,5 @@ export default defineNuxtConfig({
         landscapeWidth: 1920, // 横屏时使用的视口宽度
       },
     },
-  },
+  }
 });

+ 2 - 0
package.json

@@ -28,6 +28,8 @@
     "nuxt-swiper": "^2.0.0",
     "pinia": "^2.2.2",
     "pinyin-pro": "^3.26.0",
+    "recorder-core": "^1.3.25011100",
+    "vconsole": "^3.15.1",
     "vue": "latest",
     "vue-cropper": "^1.1.4",
     "vue-draggable-plus": "^0.6.0",

+ 39 - 2
pnpm-lock.yaml

@@ -53,6 +53,12 @@ importers:
       pinyin-pro:
         specifier: ^3.26.0
         version: 3.26.0
+      recorder-core:
+        specifier: ^1.3.25011100
+        version: 1.3.25011100
+      vconsole:
+        specifier: ^3.15.1
+        version: 3.15.1
       vue:
         specifier: latest
         version: 3.5.10
@@ -282,7 +288,7 @@ packages:
       '@babel/core': ^7.0.0-0
 
   '@babel/runtime@7.26.0':
-    resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==}
+    resolution: {integrity: sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==, tarball: https://registry.npmmirror.com/@babel/runtime/-/runtime-7.26.0.tgz}
     engines: {node: '>=6.9.0'}
 
   '@babel/standalone@7.25.6':
@@ -1628,6 +1634,13 @@ packages:
     resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
     engines: {node: '>=12.13'}
 
+  copy-text-to-clipboard@3.2.0:
+    resolution: {integrity: sha512-RnJFp1XR/LOBDckxTib5Qjr/PMfkatD0MUCQgdpqS8MdKiNUzBjAQBEN6oUy+jW7LI93BBG3DtMB2KOOKpGs2Q==, tarball: https://registry.npmmirror.com/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.0.tgz}
+    engines: {node: '>=12'}
+
+  core-js@3.40.0:
+    resolution: {integrity: sha512-7vsMc/Lty6AGnn7uFpYT56QesI5D2Y/UkgKounk87OP9Z2H9Z8kj6jzcSGAxFmUtDOS0ntK6lbQz+Nsa0Jj6mQ==, tarball: https://registry.npmmirror.com/core-js/-/core-js-3.40.0.tgz}
+
   core-util-is@1.0.3:
     resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
 
@@ -2496,6 +2509,9 @@ packages:
   ms@2.1.3:
     resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
 
+  mutation-observer@1.0.3:
+    resolution: {integrity: sha512-M/O/4rF2h776hV7qGMZUH3utZLO/jK7p8rnNgGkjKUw8zCGjRQPxB8z6+5l8+VjRUQ3dNYu4vjqXYLr+U8ZVNA==, tarball: https://registry.npmmirror.com/mutation-observer/-/mutation-observer-1.0.3.tgz}
+
   mz@2.7.0:
     resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
 
@@ -3063,6 +3079,9 @@ packages:
     resolution: {integrity: sha512-GkMg9uOTpIWWKbSsgwb5fA4EavTR+SG/PMPoAY8hkhHfEEY0/vqljY+XHqtDf2cr2IJtoNRDbrrEpZUiZCkYRw==}
     engines: {node: '>= 14.16.0'}
 
+  recorder-core@1.3.25011100:
+    resolution: {integrity: sha512-trXsCH0zurhoizT4Z22C0OsM0SDOW+2OvtgRxeLQFwxoFeqFjDjYZsbZEZUiKMJLhBvamI4K7Ic+qZ2LBo74TA==, tarball: https://registry.npmmirror.com/recorder-core/-/recorder-core-1.3.25011100.tgz}
+
   redis-errors@1.2.0:
     resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
     engines: {node: '>=4'}
@@ -3072,7 +3091,7 @@ packages:
     engines: {node: '>=4'}
 
   regenerator-runtime@0.14.1:
-    resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
+    resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==, tarball: https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz}
 
   require-directory@2.1.1:
     resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
@@ -3525,6 +3544,9 @@ packages:
     peerDependencies:
       vue: ^3.0.0
 
+  vconsole@3.15.1:
+    resolution: {integrity: sha512-KH8XLdrq9T5YHJO/ixrjivHfmF2PC2CdVoK6RWZB4yftMykYIaXY1mxZYAic70vADM54kpMQF+dYmvl5NRNy1g==, tarball: https://registry.npmmirror.com/vconsole/-/vconsole-3.15.1.tgz}
+
   vite-hot-client@0.2.3:
     resolution: {integrity: sha512-rOGAV7rUlUHX89fP2p2v0A2WWvV3QMX2UYq0fRqsWSvFvev4atHWqjwGoKaZT1VTKyLGk533ecu3eyd0o59CAg==}
     peerDependencies:
@@ -5480,6 +5502,10 @@ snapshots:
     dependencies:
       is-what: 4.1.16
 
+  copy-text-to-clipboard@3.2.0: {}
+
+  core-js@3.40.0: {}
+
   core-util-is@1.0.3: {}
 
   crc-32@1.2.2: {}
@@ -6361,6 +6387,8 @@ snapshots:
 
   ms@2.1.3: {}
 
+  mutation-observer@1.0.3: {}
+
   mz@2.7.0:
     dependencies:
       any-promise: 1.3.0
@@ -7024,6 +7052,8 @@ snapshots:
 
   readdirp@4.0.1: {}
 
+  recorder-core@1.3.25011100: {}
+
   redis-errors@1.2.0: {}
 
   redis-parser@3.0.0:
@@ -7576,6 +7606,13 @@ snapshots:
       '@vue/shared': 3.5.13
       vue: 3.5.10
 
+  vconsole@3.15.1:
+    dependencies:
+      '@babel/runtime': 7.26.0
+      copy-text-to-clipboard: 3.2.0
+      core-js: 3.40.0
+      mutation-observer: 1.0.3
+
   vite-hot-client@0.2.3(vite@5.4.8(@types/node@22.7.4)(sass@1.79.4)(terser@5.34.1)):
     dependencies:
       vite: 5.4.8(@types/node@22.7.4)(sass@1.79.4)(terser@5.34.1)

+ 8 - 0
src/middleware/01.intercept-components.global.js

@@ -0,0 +1,8 @@
+export default defineNuxtRouteMiddleware((to, from) => {
+    // 拦截路由
+    const interceptRouteRegex = [/components/, /component/];
+
+    if (interceptRouteRegex.find(o => o.test(to.fullPath))) {
+        return navigateTo('/404', {replace: true,})
+    }
+})

+ 110 - 0
src/pages/test/_index.vue

@@ -0,0 +1,110 @@
+<template>
+  <div>
+    <van-button class="mt-50" @click="openPermission">打开录音权限</van-button>
+    <div class="flex">
+      <van-button class="mt-50" @click="startRecording">开始录音</van-button>
+      <van-button class="mt-50" @click="stopRecording">关闭并下载录音</van-button>
+    </div>
+  </div>
+</template>
+<script setup>
+// https://github.com/xiangyuecn/Recorder
+// import VConsole from 'vconsole';
+import Recorder from "recorder-core";
+//需要使用到的音频格式编码引擎
+import 'recorder-core/src/engine/mp3'
+import 'recorder-core/src/engine/mp3-engine'
+//可选的扩展编码引擎
+import 'recorder-core/src/extensions/waveview'
+// new VConsole({ theme: 'dark' });
+
+const videoConfig = {
+  type: 'mp3',
+  bitRate: 16,
+  sampleRate: 16000,
+  duration: 0,
+  durationTxt: "0",
+  powerLevel: 0,
+  logs: []
+}
+let recorderInstance = null
+const openPermission = () => {
+  recorderInstance = Recorder({
+    type: 'mp3',
+    bitRate: 16,
+    sampleRate: 16000,
+    duration: 0,
+    onProcess: function (buffers, powerLevel, duration, sampleRate) {
+      console.log('录制中~')
+    }
+  });
+  recorderInstance.open(function () {
+    alert('录音已打开')
+  }, function (msg, isUserNotAllow) {
+    alert('录音打开失败')
+  });
+}
+
+const startRecording = () => {
+  try {
+    if (!recorderInstance || !Recorder.IsOpen()) {
+      alert('未打开录音')
+      return
+    }
+    alert('开始录音')
+    recorderInstance.start()
+  } catch (e) {
+    alert(e)
+  }
+}
+const stopRecording = () => {
+  if (!recorderInstance || !Recorder.IsOpen()) {
+    alert('未打开录音')
+    return
+  }
+  recorderInstance.stop(
+      (blob, duration) => {
+        alert('录制成功')
+        const o = {
+          blob: blob,
+          duration: duration,
+          durationTxt: formatMs(duration),
+          rec: recorderInstance
+        }
+        let name = "rec-" + o.duration + "ms-" + (o.rec.set.bitRate || "-") + "kbps-" + (o.rec.set.sampleRate || "-") + "hz." + (o.rec.set.type || (/\w+$/.exec(o.blob.type) || [])[0] || "unknown");
+        let downA = document.createElement("A");
+        downA.href = (window.URL || webkitURL).createObjectURL(o.blob);
+        downA.download = name;
+        downA.click();
+        alert('下载成功')
+      },
+      (e) => {
+        alert(e + '录音失败')
+      }
+  )
+
+
+}
+
+
+const formatMs = (ms, all) => {
+  let ss = ms % 1000;
+  ms = (ms - ss) / 1000;
+  let s = ms % 60;
+  ms = (ms - s) / 60;
+  let m = ms % 60;
+  ms = (ms - m) / 60;
+  let h = ms;
+  let t = (h ? h + ":" : "")
+      + (all || h + m ? ("0" + m).substr(-2) + ":" : "")
+      + (all || h + m + s ? ("0" + s).substr(-2) + "″" : "")
+      + ("00" + ss).substr(-3);
+  return t;
+}
+
+</script>
+<style scoped lang="scss">
+* {
+  user-select: none;
+}
+</style>

+ 59 - 34
src/pages/test/index.vue

@@ -1,47 +1,72 @@
 <template>
-  <div>
-    <van-index-bar :index-list="sortStringToAZ.sortIndex">
-      <template v-for="item in list">
-        <van-index-anchor :index="item[0]" />
-        <template v-for="o in item[1]">
-          <van-cell :title="o.userInfo.name" />
-        </template>
+  <div class="test-page flex flex-col" oncontextmenu="return false;">
+    <div>
+
+    </div>
+
+    <div class="flex mt-auto py-20 flex-col bg-[#999]">
+      <template v-if="inRecording">
+        <div class="flex flex-row px-20 justify-around">
+          <van-button @click="cancelRecording">取消</van-button>
+          <van-button @click="handleStopRecording" type="warning">完成并发送</van-button>
+        </div>
+        <div class="flex flex-row p-20 justify-center text-white text-base">
+          <van-loading class="mr-5"/>
+          正在说话
+        </div>
+      </template>
+      <template v-else>
+        <van-button type="primary" @click="handleTouchstart">点击说话</van-button>
       </template>
-    </van-index-bar>
+
+    </div>
   </div>
 </template>
 <script setup>
-import Mock from 'mockjs'
+// import VConsole from 'vconsole';
+// new VConsole({ theme: 'dark' });
+
+import {useRecording} from "~/pages/test/useRecording";
+
+const {inRecording, startRecording, stopRecording, cancelRecording} = useRecording()
+
+const handleTouchstart = async () => {
+  try {
+    await startRecording()
+  } catch (e) {
 
-const list = ref(new Map([]));
-const initData = async () => {
+  } finally {
+
+  }
+}
+const handleStopRecording = async () => {
   try {
-    const {originalData} = Mock.mock({
-      'originalData|100': [
-        {
-          userInfo: {
-            'name|1': '@cname'
-          }
-        }
-      ]
-    })
-    originalData.push(...[
-      {name: '重庆'},
-      {name: ''},
-      {name: ' a2'},
-      {name: 'ddf'},
-      {name: '~'},
-    ])
-    const {sortListMap} = sortStringToAZ.sort(originalData, 'userInfo.name')
-
-    console.log(sortListMap, 'sortListMap')
-    list.value = sortListMap
+    const {success, errorMessage, audio} = await stopRecording()
+    if (!success) {
+      showToast(errorMessage);
+      return
+    }
+    console.log(audio, '---audio---')
   } catch (e) {
-      console.log(e, 'ee')
+
+  } finally {
+
   }
 }
+
 onMounted(() => {
-  initData()
+  /*  document.addEventListener('contextmenu', function(e) {
+      e.preventDefault();
+    });*/
 })
 </script>
-<style scoped lang="scss"></style>
+<style scoped lang="scss">
+* {
+  user-select: none;
+}
+
+.test-page {
+  height: calc(100vh - 50px);
+  width: 100%;
+}
+</style>

+ 184 - 0
src/pages/test/useRecording.js

@@ -0,0 +1,184 @@
+// https://github.com/xiangyuecn/Recorder
+
+import Recorder from "recorder-core";
+//需要使用到的音频格式编码引擎
+import 'recorder-core/src/engine/mp3'
+import 'recorder-core/src/engine/mp3-engine'
+//可选的扩展编码引擎
+import 'recorder-core/src/extensions/waveview'
+
+/*const videoConfig = {
+    type: 'mp3',
+    bitRate: 16,
+    sampleRate: 16000,
+    duration: 0,
+    durationTxt: "0",
+    powerLevel: 0,
+    logs: []
+}*/
+export const useRecording = () => {
+  let recorderInstance = null;
+  let inRecording = ref(false)
+  const openPermission = () => {
+    return new Promise((resolve, reject) => {
+      if (recorderInstance && Recorder.IsOpen()) resolve()
+      if (!recorderInstance) {
+        recorderInstance = Recorder({
+          type: 'mp3',
+          bitRate: 16,
+          sampleRate: 16000,
+          duration: 0,
+          onProcess: function (buffers, powerLevel, duration, sampleRate) {
+            console.log('录制中~')
+            inRecording.value = true
+          }
+        });
+        recorderInstance.open(
+          () => {
+            resolve()
+          },
+          () => {
+            reject()
+          }
+        )
+        return
+      }
+      if (!Recorder.IsOpen()) {
+        recorderInstance.open(
+          () => {
+            resolve()
+          },
+          () => {
+            reject()
+          }
+        )
+        return;
+      }
+    })
+  }
+
+  const startRecording = async () => {
+    try {
+      await openPermission()
+      recorderInstance.start()
+    } catch (e) {
+      console.error(e, 'startRecording')
+    }
+  }
+  const stopRecording = async () => {
+    return new Promise((resolve, reject) => {
+      if (!recorderInstance || !Recorder.IsOpen()) {
+        console.log('未开启录音!')
+        resolve({
+          success: false,
+          errorMessage: '未开启录音'
+        })
+      }
+
+      recorderInstance.stop(
+        (blob, duration) => {
+          inRecording.value = false
+          console.log('----录制完成-----', blob, duration)
+          if ((duration / 1000) < 1) {
+            console.log(duration, '录音小于一秒')
+            resolve({
+              success: false,
+              errorMessage: '说话时间太短'
+            })
+          } else {
+            console.log(duration, '录音合格')
+            resolve({
+              success: true,
+              audio: {
+                blob: blob,
+                duration: duration,
+                durationTxt: formatMs(duration),
+                href: (window.URL || webkitURL).createObjectURL(blob)
+              },
+              errorMessage: ''
+            })
+            /*const o = {
+              blob: blob,
+              duration: duration,
+              durationTxt: formatMs(duration),
+              rec: recorderInstance
+            }
+            let name = "rec-" + o.duration + "ms-" + (o.rec.set.bitRate || "-") + "kbps-" + (o.rec.set.sampleRate || "-") + "hz." + (o.rec.set.type || (/\w+$/.exec(o.blob.type) || [])[0] || "unknown");
+            let downA = document.createElement("A");
+            downA.href = (window.URL || webkitURL).createObjectURL(o.blob);
+            downA.download = name;
+            downA.click();
+            alert('下载成功')*/
+          }
+        },
+        (e) => {
+          inRecording.value = false
+          resolve({
+            success: false,
+            errorMessage: e
+          })
+        }
+      )
+    })
+  }
+  const cancelRecording = async () => {
+    return new Promise((resolve) => {
+      if (!recorderInstance || !Recorder.IsOpen()) {
+        console.log('未开启录音!')
+        resolve({
+          success: true,
+          errorMessage: ''
+        })
+      }
+
+      recorderInstance.stop(
+        (blob, duration) => {
+          inRecording.value = false
+          resolve({
+            success: true,
+            errorMessage: ''
+          })
+
+        },
+        (e) => {
+          inRecording.value = false
+          resolve({
+            success: false,
+            errorMessage: e
+          })
+        }
+      )
+    })
+  }
+  const formatMs = (ms, all) => {
+    let ss = ms % 1000;
+    ms = (ms - ss) / 1000;
+    let s = ms % 60;
+    ms = (ms - s) / 60;
+    let m = ms % 60;
+    ms = (ms - m) / 60;
+    let h = ms;
+    let t = (h ? h + ":" : "")
+      + (all || h + m ? ("0" + m).substr(-2) + ":" : "")
+      + (all || h + m + s ? ("0" + s).substr(-2) + "″" : "")
+      + ("00" + ss).substr(-3);
+    return t;
+  }
+
+  const closeRecording = () => {
+    recorderInstance.close()
+  }
+
+  onUnmounted(() => {
+    closeRecording()
+  })
+  return {
+    inRecording, // 是否在录音中
+    openPermission,// 开启录音系统权限
+    startRecording,// 开始录音
+    stopRecording,// 停止录音
+    cancelRecording,//  取消录音
+    closeRecording,// 关闭录音权限
+  }
+
+}

+ 48 - 0
src/plugins/longPress.js

@@ -0,0 +1,48 @@
+export default defineNuxtPlugin(nuxtApp => {
+  // 使用nuxtApp做一些操作
+  nuxtApp.vueApp.directive('longPress', {
+    // 当被绑定的元素插入到DOM中时……
+    inserted: function(el, binding, vnode) {
+      let pressTimer = null;
+
+      // 监听touchstart事件
+      el.addEventListener("touchstart", e => {
+        // 阻止默认事件,比如触摸滚动
+        e.preventDefault();
+
+        // 清除之前的定时器(如果存在)
+        if (pressTimer !== null) {
+          clearTimeout(pressTimer);
+        }
+
+        // 设置定时器,等待一段时间后执行长按逻辑
+        pressTimer = setTimeout(() => {
+          // 调用传入的函数,并传入事件对象
+          if (typeof binding.value === "function") {
+            // 判定为长按
+            binding.value(true, e);
+          }
+        }, 500); // 假设长按时间为500毫秒
+      });
+
+      // 监听touchend和touchcancel事件来取消定时器
+      el.addEventListener("touchend", e => {
+        // 取消长按
+        binding.value(false, e);
+        clearTimeout(pressTimer);
+        pressTimer = null;
+      });
+
+      el.addEventListener("touchcancel", e => {
+        clearTimeout(pressTimer);
+        pressTimer = null;
+      });
+    },
+    // 当绑定元素的父组件被卸载时,解绑事件
+    unbind: function(el) {
+      el.removeEventListener("touchstart");
+      el.removeEventListener("touchend");
+      el.removeEventListener("touchcancel");
+    }
+  })
+})