소스 검색

feat:修改录音识别列表中拼接对话逻辑,同一个人说话在一个对话框中。添加注释

hanyang 1 년 전
부모
커밋
3c4098d813
31개의 변경된 파일446개의 추가작업 그리고 357개의 파일을 삭제
  1. 0 0
      dist/assets/Index-B19H-I0s.css
  2. 0 0
      dist/assets/Index-B8MTkask.js
  3. 0 0
      dist/assets/Index-D-aGt55S.js
  4. 0 0
      dist/assets/Index-DINdl0Py.css
  5. 0 0
      dist/assets/index-1J2dDefN.js
  6. 0 0
      dist/assets/index-C4WH646L.css
  7. 1 1
      dist/assets/index-Dg675R0R.js
  8. 1 1
      dist/index.html
  9. 0 2
      src/base/ajax/replace-proxy-url.js
  10. 2 1
      src/base/store/use-current-page.js
  11. 39 8
      src/base/store/use-meeting.js
  12. 4 0
      src/modules/home/components/TopTools.vue
  13. 24 10
      src/modules/meeting-detail/MeetingSummary.vue
  14. 1 8
      src/modules/meeting-detail/RecordDetail.vue
  15. 18 2
      src/modules/meeting-detail/TopTools.vue
  16. 7 1
      src/modules/meeting-detail/hooks/use-websocket.js
  17. 0 2
      src/modules/meeting-detail/hooks/useAudioDevices.js
  18. 43 19
      src/modules/meeting-detail/record-detail/AudioPlayer.vue
  19. 12 2
      src/modules/meeting-detail/record-detail/RecordList.vue
  20. 56 9
      src/modules/meeting-detail/record-detail/RecordPlayer.vue
  21. 29 0
      src/modules/meeting-detail/record-detail/audio-processor_v2.js
  22. 4 4
      src/modules/meeting-detail/record-detail/record.js
  23. 135 0
      src/modules/meeting-detail/record-detail/record_v2.js
  24. 0 285
      src/modules/meeting-detail/record-detail/record_v2.vue
  25. 59 0
      src/modules/meeting-detail/top-tools/ModalCloseRecord.vue
  26. 1 0
      src/modules/meeting-detail/top-tools/SearchAndUpload.vue
  27. 1 0
      src/views/Index.vue
  28. 3 0
      src/views/home/MenuList.vue
  29. 2 1
      src/views/meeting-detail/index.vue
  30. 3 1
      src/views/routes.js
  31. 1 0
      vite.config.mjs

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/Index-B19H-I0s.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/Index-B8MTkask.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/Index-D-aGt55S.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/Index-DINdl0Py.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-1J2dDefN.js


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
dist/assets/index-C4WH646L.css


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 1 - 1
dist/assets/index-Dg675R0R.js


+ 1 - 1
dist/index.html

@@ -6,7 +6,7 @@
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>Ai会议</title>
     <script type="module" src="/config.js"></script>
-    <script type="module" crossorigin src="/assets/index-jBSRnDEX.js"></script>
+    <script type="module" crossorigin src="/assets/index-Dg675R0R.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CWhsa_17.css">
   </head>
   <body>

+ 0 - 2
src/base/ajax/replace-proxy-url.js

@@ -3,8 +3,6 @@ import { getConfigField } from '../../config/index';
 /**
  * 处理请求路径
  * 当在开发模式下,使用 vite 中配置 proxy,代理所有的 /api/ 相关的请求
- * 开发模式下使用外网地址:http://smartai.com:8191
- * 使用外网地址的原因是:有其他成员在非内网环境下进行共同开发,故无法使用内网地址
  *
  * 当在production模式下,通过此处的方法,将所有 /api/ 请求地址,调整为正常的地址
  * @param {object} config

+ 2 - 1
src/base/store/use-current-page.js

@@ -14,10 +14,11 @@ export const useCurrentPageStore = defineStore('current_page', {
   // },
   state: () => {
     return {
-      page: 'home'
+      page: 'home' // 当前页面
     };
   },
   actions: {
+    // 切换页面
     changCurrentPage(page) {
       this.page = page;
     }

+ 39 - 8
src/base/store/use-meeting.js

@@ -60,23 +60,56 @@ export const useMeetingStore = defineStore('meeting', {
         this.current_meeting_data[key] = item[key];
       }
     },
-    // 添加数据到record_list
-    pushDataToRecordList(item) {
+    /**
+     * @description 添加一个对话框到record_list中
+     * is_end:默认当前说话的人没有结束当前对话。
+     *
+    */
+    pushDataToRecordListItem(item) {
       const useCurrentUser = useCurrentUserStore();
       // 查询user表中是否有对应用户
-      console.log(useCurrentUser.user_list, 3333);
       const speaker = useCurrentUser.user_list.find((user) => user.id === item.speaker);
-      console.log(speaker, 1111);
-      console.log(item.speaker, 2222);
       const data = {
         ...item,
         duration: formatTime(item.times),
         content: item.text,
         name: (speaker && speaker.name) || item.speaker, //没有查到就默认显示对应的speaker
-        speaker_id: item.speaker
+        speaker_id: item.speaker,
+        is_end: false,
       };
       this.current_meeting_data.record_list.push(data);
     },
+    // 添加数据到record_list
+    pushDataToRecordList(item) {
+      // 如果刚开始录音,直接添加到record_list中
+      if (!this.current_meeting_data.record_list.length) {
+        this.pushDataToRecordListItem(item);
+      } else {
+        /**
+         * 否则需要判断最后一项是否已经结束,如果结束,新开始一个对话框
+         * */
+        const last_record_item =
+          this.current_meeting_data.record_list[this.current_meeting_data.record_list.length - 1];
+        if (!last_record_item.is_end) {
+          // 每句话完成后加一个,号
+          last_record_item.content += `,` + item.text;
+          // 替换最后一项
+          this.current_meeting_data.record_list[this.current_meeting_data.record_list.length - 1] = last_record_item;
+        } else {
+          this.pushDataToRecordListItem(item);
+        }
+      }
+    },
+    /**
+     * 当检测到socket返回close后,结束这一段内容
+     *
+     */
+    closeLastRecordItem() {
+      const last_record_item = this.current_meeting_data.record_list[this.current_meeting_data.record_list.length - 1];
+      last_record_item.is_end = true;
+      // 替换最后一项
+      this.current_meeting_data.record_list[this.current_meeting_data.record_list.length - 1] = last_record_item;
+    },
     // 更新current_meeting_data.record_list中name数据
     upDateRecordListName() {
       if (
@@ -86,8 +119,6 @@ export const useMeetingStore = defineStore('meeting', {
       ) {
         this.current_meeting_data.record_list.forEach((item) => {
           const speaker = useCurrentUserStore().user_list.find((user) => user.id === item.speaker);
-          console.log(speaker, 1111);
-          console.log(item.speaker, 2222);
           item.name = (speaker && speaker.name) || item.speaker;
         });
       }

+ 4 - 0
src/modules/home/components/TopTools.vue

@@ -46,6 +46,10 @@ const toStartMeeting = () => {
   bus_change_page.emit('meeting-detail');
   //修改会议类型,用来区分创建会议和编辑会议
   useMeeting.changeMeetType('startMeet');
+  /**
+   * 点击开始会议后,打开新的页面(如需改造,可从这里下手)
+   * */
+  // window.open('#/detail', '_blank');
 };
 // 导入文件
 const toUploadFile = () => {

+ 24 - 10
src/modules/meeting-detail/MeetingSummary.vue

@@ -46,15 +46,24 @@ import copyText from '../../3rd-libs/clipboard';
 import { Message } from '@arco-design/web-vue';
 
 const useMeeting = useMeetingStore();
-
+/**
+ * 存放生成会议纪要的数据
+ * */
 const summary_data = ref([]);
 
 const meeting_summary_body = ref(null);
 
-// 加载会议纪要数据
+/**
+ * 加载会议纪要数据
+ * */
 const is_loading_summary = ref(false);
 
-// 获取会议纪要列表
+/**
+ * 获取会议纪要列表
+ * 注意:该方法是在结束录音或者点击历史会议时会被调用,
+ * 当结束录音是调用时,会出现会议纪要还在生成中的情况,返回的res.data.rows为空,
+ * 所以需要等待一段时间,再重新获取会议纪要列表,直到返回的res.data.rows不为空,才继续执行。
+ * */
 const getMeetingSummaryListFun = async () => {
   is_loading_summary.value = true;
   try {
@@ -72,6 +81,7 @@ const getMeetingSummaryListFun = async () => {
         useMeeting.changeDisabledInputOrBtn(false);
         scrollToBottom();
       } else {
+        // 等待3s后重新获取会议纪要列表
         await new Promise((resolve) => setTimeout(resolve, 3000));
         await getMeetingSummaryListFun();
       }
@@ -83,7 +93,9 @@ const getMeetingSummaryListFun = async () => {
   }
 };
 
-// 问答
+/**
+ *  问答
+ */
 const conversationToAi = async (data) => {
   addLoadingToList();
   const res = await conversation(data);
@@ -99,8 +111,7 @@ const conversationToAi = async (data) => {
   }
 };
 /**
- * @description 发送消息
- *
+ * 发送消息
  * */
 const handleSubmit = (input_text_value) => {
   if (!input_text_value.trim()) return;
@@ -108,7 +119,7 @@ const handleSubmit = (input_text_value) => {
   // 发送消息添加到列表中
   const questionItem = {
     content: input_text_value,
-    speecher: 1
+    speecher: 1 // 1: 用户,2:AI
   };
   addMessageToList(questionItem);
   // 禁用输入框和按钮禁用
@@ -145,7 +156,7 @@ const addLoadingToList = () => {
     type: 'loading',
     content: '会议纪要生成中...',
     format_content: '<p>会议纪要生成中...</p>',
-    speecher: 2
+    speecher: 2 // 1: 用户,2:AI
   };
   addMessageToList(loadingItem);
   // 滚动到底部
@@ -161,7 +172,9 @@ const scrollToBottom = () => {
     }
   });
 };
-
+/**
+ * 重新生成
+ * */
 const regenerateSummaryFun = async (id, content) => {
   const data = {
     message: '重新生成',
@@ -182,10 +195,11 @@ const toRegenerate = (data) => {
       useMeeting.changeDisabledInputOrBtn(true);
       const res = await regenerateSummaryFun(item.id, item.content);
       if (res.code == 200) {
-        item.type = 'stream';
+        item.type = 'stream'; //重新生成后,给AI返回的数据加一个type标识为stream,暂时没有用到该属性值做判断
         item.content = res.data.content;
         item.updated_at = res.data.updated_at;
         item.format_content = formatMarkdownString(res.data.content);
+        // 解除输入框和按钮禁用
         useMeeting.changeDisabledInputOrBtn(false);
       }
     }

+ 1 - 8
src/modules/meeting-detail/RecordDetail.vue

@@ -31,11 +31,9 @@ import AudioPlayer from './record-detail/AudioPlayer.vue';
 import RecordPlayer from './record-detail/RecordPlayer.vue';
 import ModelMediaError from './ModelMediaError.vue';
 import { useMeetingStore } from '../../base/store/use-meeting';
-import { useCurrentPageStore } from '../../base/store/use-current-page';
 import { Message } from '@arco-design/web-vue';
 
 const useMeeting = useMeetingStore();
-const useCurrentPage = useCurrentPageStore();
 
 const props = defineProps({
   audio_player_url: {
@@ -53,16 +51,11 @@ const show_media_error = ref(false);
 
 // 结束录音
 const closeRecording = () => {
-  // TODO:结束录音操作
+  // 结束录音操作
   Message.success('录音结束');
-  // useCurrentPage.changCurrentPage('home');
   emit('closeRecord');
-  //
 };
 
-// 替换数据
-const changeMeetData = () => {};
-
 const handleError = (err) => {
   show_media_error.value = true;
 };

+ 18 - 2
src/modules/meeting-detail/TopTools.vue

@@ -11,12 +11,18 @@
     <div class="right-tools">
       <SearchAndUpload></SearchAndUpload>
     </div>
+    <ModalCloseRecord
+      :visible.async="show_close_record"
+      @ok="handlerOk"
+      @cancel="show_close_record = false"
+    ></ModalCloseRecord>
   </div>
 </template>
 
 <script setup>
 import { ref, nextTick } from 'vue';
 import SearchAndUpload from './top-tools/SearchAndUpload.vue';
+import ModalCloseRecord from './top-tools/ModalCloseRecord.vue';
 import { bus_change_page, bus_stop_record } from '../../base/event-bus.js';
 import { updateMeetingDetail } from '../../apis/home.js';
 import { useMeetingStore } from '../../base/store/use-meeting.js';
@@ -28,6 +34,7 @@ const current_name = ref(useMeeting.current_meeting_data.name);
 const show_edit = ref(false);
 const inputRef = ref(null);
 const updated_at = ref(useMeeting.current_meeting_data.updated_at);
+const show_close_record = ref(false);
 
 const toEdit = () => {
   show_edit.value = true;
@@ -37,12 +44,21 @@ const toEdit = () => {
 };
 
 const backHome = () => {
-  // 初始化数据后会带首页
+  if (useMeeting.meet_type == 'startMeet') {
+    console.log('startMeet');
+    show_close_record.value = true;
+  } else {
+    handlerOk();
+  }
 
+  // TODO:加提示
+};
+const handlerOk = () => {
+  console.log('handlerOk');
+  // 初始化数据后回到首页
   useMeeting.initMeetingData();
   bus_stop_record.emit();
   bus_change_page.emit('home');
-  // TODO:加提示
 };
 const saveName = (e) => {
   // TODO:更新名称

+ 7 - 1
src/modules/meeting-detail/hooks/use-websocket.js

@@ -5,9 +5,11 @@ import { useMeetingStore } from '../../../base/store/use-meeting';
 import { getConfigField } from '../../../config/index';
 
 const useMeeting = useMeetingStore();
+
 const ws_server = getConfigField('WEBSOCKET_BASE_URL');
 const ws_url = ws_server + `/api/asr/realtime`;
 
+// 初始化时发送的数据
 const init_send_message_data = {
   ext: '.wav',
   meeting_id: useMeeting.meet_id
@@ -37,10 +39,14 @@ export function useWebSocket() {
       // console.log('收到消息:', event.data);
       messages.value.push(event.data);
       const record_data = JSON.parse(event.data);
-      // TODO:将数据存储到store中
+      // 数据存储到store中
       if (record_data && record_data.type == 'stream') {
+        // useMeeting.pushDataToRecordList(record_data);
         useMeeting.pushDataToRecordList(record_data);
       }
+      if (record_data && record_data.type === 'close') {
+        useMeeting.closeLastRecordItem();
+      }
     };
 
     // 连接关闭时

+ 0 - 2
src/modules/meeting-detail/hooks/useAudioDevices.js

@@ -107,8 +107,6 @@ export function useAudioDevices() {
     microphones,
     speakers,
     error,
-    switchMicrophone,
-    switchSpeaker,
     getMicrophoneAndSpeakerInfo, // 方便外部手动触发重新获取
     switchMicrophone, // 切换麦克风
     switchSpeaker // 切换扬声器

+ 43 - 19
src/modules/meeting-detail/record-detail/AudioPlayer.vue

@@ -82,14 +82,14 @@ const speedBtn = computed(() => {
   return speed.value.toFixed(1);
 });
 
-// 初始化音频
-onMounted(() => {
-  // audio.value = new Audio('https://www.sample-videos.com/audio/mp3/crowd-cheering.mp3');
-  // console.log('props.audio_player_url', props.audio_player_url);
+/**
+ * 初始化音频
+ * */
+const initAudio = () => {
   audio.value = new Audio();
-  audio.value.src = BASE_URL + props.audio_player_url; // 设置音频源
+  audio.value.src = BASE_URL + props.audio_player_url; // 设置音频源、
+  console.log(audio.value.src);
   audio.value.load();
-  // console.log('audio.value', audio.value);
   audio.value.addEventListener('timeupdate', updateTime);
   audio.value.addEventListener('loadedmetadata', updateDuration);
   audio.value.addEventListener('ended', onAudioEnded); // 添加监听播放结束事件
@@ -101,9 +101,11 @@ onMounted(() => {
   audio.value.addEventListener('error', (e) => {
     console.error('Error loading audio:', e);
   });
-});
+};
 
-// 播放/暂停切换
+/**
+ * 播放/暂停切换
+ * */
 const togglePlay = () => {
   if (isPlaying.value) {
     audio.value.pause();
@@ -113,25 +115,33 @@ const togglePlay = () => {
   isPlaying.value = !isPlaying.value;
 };
 
-// 更新当前播放时间
+/**
+ * 更新当前播放时间
+ * */
 const updateTime = () => {
   if (!isDragging.value) {
     currentTime.value = audio.value.currentTime;
   }
 };
 
-// 更新音频总时长
+/**
+ * 更新音频总时长
+ * */
 const updateDuration = () => {
   duration.value = audio.value.duration;
 };
 
-// 开始拖动进度条
+/**
+ * 开始拖动进度条
+ * */
 const startDragging = (event) => {
   isDragging.value = true;
   setProgress(event);
 };
 
-// 拖动过程中更新进度条
+/**
+ * 拖动过程中更新进度条
+ * */
 const dragProgress = (event) => {
   if (isDragging.value) {
     setProgress(event);
@@ -140,7 +150,9 @@ const dragProgress = (event) => {
   }
 };
 
-// 结束拖动进度条
+/**
+ * 结束拖动进度条
+ * */
 const endDragging = (event) => {
   if (isDragging.value) {
     setProgress(event);
@@ -150,7 +162,9 @@ const endDragging = (event) => {
   }
 };
 
-// 根据鼠标位置更新进度
+/**
+ * 根据鼠标位置更新进度
+ * */
 const setProgress = (event) => {
   const rect = progressBar.value.getBoundingClientRect();
   const offsetX = event.clientX - rect.left;
@@ -171,7 +185,9 @@ const toggleSpeed = () => {
   audio.value.playbackRate = speed.value; // 更新播放速度
 };
 
-// 快进/快退功能
+/**
+ * 快进/快退功能
+ * */
 const seek = (time) => {
   const newTime = currentTime.value + time;
   // 确保时间不会小于0且不超过总时长
@@ -179,21 +195,29 @@ const seek = (time) => {
   audio.value.currentTime = currentTime.value;
 };
 
-// 播放结束处理
+/**
+ * 播放结束处理
+ * */
 const onAudioEnded = () => {
   isPlaying.value = false; // 播放结束时更新按钮状态
   currentTime.value = 0; // 重置播放时间
 };
-// 打开热词配置
+/**
+ * 打开热词配置
+ * */
 const handleHotClick = () => {
   show_hot.value = true;
 };
 
-// 关闭热词配置
+/**
+ * 关闭热词配置
+ * */
 const cancelModelHot = () => {
   show_hot.value = false;
 };
-
+onMounted(() => {
+  initAudio();
+});
 onBeforeUnmount(() => {
   audio.value.pause();
   audio.value.removeEventListener('timeupdate', updateTime);

+ 12 - 2
src/modules/meeting-detail/record-detail/RecordList.vue

@@ -17,7 +17,7 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted } from 'vue';
+import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
 import RecordListItem from './RecordListItem.vue';
 
 import { useMeetingStore } from '../../../base/store/use-meeting.js';
@@ -28,9 +28,19 @@ const scroll_height = ref(0);
 const recordList = computed(() => {
   return useMeeting.current_meeting_data.record_list;
 });
+const updateScrollHeight = () => {
+  scroll_height.value = document.getElementById('record-list').offsetHeight;
+};
 
 onMounted(() => {
-  scroll_height.value = document.getElementById('record-list').offsetHeight;
+  updateScrollHeight();
+  // 监听窗口大小变化
+  window.addEventListener('resize', updateScrollHeight);
+});
+
+onBeforeUnmount(() => {
+  // 组件卸载时移除事件监听器
+  window.removeEventListener('resize', updateScrollHeight);
 });
 </script>
 <style scoped lang="css">

+ 56 - 9
src/modules/meeting-detail/record-detail/RecordPlayer.vue

@@ -31,14 +31,33 @@ const { sendMessage, isConnected, disconnect, messages } = useWebSocket();
 
 const emit = defineEmits(['close-recording', 'error']);
 
+/**
+ *  是否正在录音判断
+ */
 const isRecording = ref(false);
+/**
+ * 录音持续时长
+ * */
 const duration = ref(0);
+/**
+ * 记录当前录音状态
+ *
+ * 页面暂未使用到
+ * */
 const statusMessage = ref('Press to start recording');
+/**
+ * 拾音器对象
+ * */
 let record = null;
 let timeInterval = null;
 
 // Record status
 
+/**
+ * 录音开关切换
+ * 开始录音:isRecording.value = true
+ * 暂停录音:isRecording.value = false
+ * */
 const toggleRecording = async () => {
   if (isRecording.value) {
     stopRecording();
@@ -47,6 +66,9 @@ const toggleRecording = async () => {
   }
 };
 
+/**
+ * 开始录音
+ * */
 const startRecording = () => {
   console.log('Start Recording');
   if (!record) {
@@ -60,20 +82,18 @@ const startRecording = () => {
   timeInterval = setInterval(() => {
     duration.value += 1; // 每秒增加一次
     const audioBlob = record.getBlob();
-    // console.log('Blob size: ', audioBlob.size);
-
-    // Read and log the Blob content for debugging
     const reader = new FileReader();
     reader.onloadend = function () {
-      // console.log('Blob content: ', new Uint8Array(reader.result));
+      // websocket实时发送pcm流给服务端
       sendMessage(audioBlob);
-      // console.log(messages);
       record.clear();
     };
     reader.readAsArrayBuffer(audioBlob);
   }, 500);
 };
-
+/**
+ * 暂停录音
+ * */
 const stopRecording = () => {
   console.log('Stop Recording');
   if (isConnected && record) {
@@ -84,6 +104,9 @@ const stopRecording = () => {
   statusMessage.value = 'Recording stopped';
 };
 
+/**
+ * 结束录音
+ * */
 const closeRecording = () => {
   stopRecording();
   disconnect();
@@ -91,6 +114,16 @@ const closeRecording = () => {
   emit('close-recording');
 };
 
+/**
+ * 初始化拾音器对象
+ *
+ * 思路:
+ * 1. 使用到浏览器自带的API getUserMedia
+ * 2. 创建一个Recorder对象,传入MediaStream对象
+ * 3. 调用Recorder对象的start方法开始录音
+ * 4. 调用Recorder对象的stop方法停止录音
+ * 5. 调用Recorder对象的clear方法清除录音数据
+ * */
 const initRecorder = () => {
   const init = (rec) => {
     record = rec;
@@ -102,12 +135,12 @@ const initRecorder = () => {
     navigator.mediaDevices
       .getUserMedia({ audio: true })
       .then((mediaStream) => {
-        init(new Recorder(mediaStream));
+        init(new Recorder(mediaStream)); // 初始化 Recorder 对象
         startRecording();
+        console.log('init recorder');
       })
       .catch((error) => {
         console.log(error);
-        // null
         emit('error');
         isRecording.value = false;
         duration.value = 0;
@@ -115,17 +148,28 @@ const initRecorder = () => {
   }
 };
 const handleBeforeUnload = (e) => {
-  const message = '你确定要刷新页面吗?';
+  console.log(e);
+  const message = '系统可能不会保存您所做的更改';
 
   // 现代浏览器要求设置返回值,才能触发确认框
   e.returnValue = message; // 标准方式
   return message; // 部分旧浏览器的兼容性
 };
 
+const handleUnload = (e) => {
+  closeRecording();
+};
+/**
+ * 当点击回到主页按钮,触发结束录音操作
+ * */
 bus_stop_record.on(() => {
   console.log('closeRecording');
   closeRecording();
 });
+
+/**
+ * 实时监测是否连接websocket,如果断开,则停止录音
+ * */
 watch(
   () => isConnected,
   (nerVal) => {
@@ -139,9 +183,12 @@ watch(
 onMounted(() => {
   initRecorder();
   window.addEventListener('beforeunload', handleBeforeUnload);
+  window.addEventListener('unload', handleUnload);
 });
 onUnmounted(() => {
   window.removeEventListener('beforeunload', handleBeforeUnload);
+  window.removeEventListener('unload', handleUnload);
+  bus_stop_record.off();
 });
 </script>
 

+ 29 - 0
src/modules/meeting-detail/record-detail/audio-processor_v2.js

@@ -0,0 +1,29 @@
+class AudioProcessor extends AudioWorkletProcessor {
+  constructor() {
+    super();
+    this.channelCount = 1; // 单声道
+  }
+
+  process(inputs, outputs, parameters) {
+    const input = inputs[0];
+    const output = outputs[0];
+
+    if (input) {
+      // 处理输入音频数据,并将其复制到输出
+      for (let channel = 0; channel < this.channelCount; channel++) {
+        const inputChannel = input[channel];
+        const outputChannel = output[channel];
+        outputChannel.set(inputChannel); // 直接传递数据到输出
+      }
+
+      // 将音频数据发送给主线程
+      this.port.postMessage(input[0]);
+    }
+
+    // 返回 true,表示继续处理音频
+    return true;
+  }
+}
+
+// 注册 AudioProcessor
+registerProcessor('audio-processor', AudioProcessor);

+ 4 - 4
src/modules/meeting-detail/record-detail/record.js

@@ -84,17 +84,17 @@ export class Recorder {
 
   // 停止录音
   stop() {
-    this.recorder.disconnect(); // 断开音频处理节点的连接
+    this.recorder?.disconnect();
   }
 
   // 获取录音的 PCM Blob 数据
   getBlob() {
-    return this.audioData.encodePCM(); // 返回编码后的 PCM 数据
+    return this.audioData.encodePCM();
   }
 
-  // 清音频数据
+  // 清空缓冲区音频数据
   clear() {
-    this.audioData.clear(); // 清空缓冲区
+    this.audioData.clear();
   }
 
   // 缩小音频采样率(即重采样)

+ 135 - 0
src/modules/meeting-detail/record-detail/record_v2.js

@@ -0,0 +1,135 @@
+import { reactive } from 'vue';
+
+// 定义音频数据对象,用于存储音频缓冲区数据并进行PCM编码
+const audioData = reactive({
+  size: 0, // 缓冲区的总大小
+  buffer: [], // 存储音频数据的缓冲区
+  inputSampleRate: 48000, // 输入音频的采样率
+  outputSampleRate: 16000, // 输出音频的采样率
+  sampleBits: 16, // 采样位深(即每个样本的位数)
+
+  // 清空缓冲区
+  clear: function () {
+    this.buffer = []; // 清空缓冲区
+    this.size = 0; // 重置缓冲区大小
+  },
+
+  // 将输入的音频数据添加到缓冲区
+  input: function (data) {
+    this.buffer.push(new Float32Array(data)); // 将数据转换为 Float32Array 类型并加入缓冲区
+    this.size += data.length; // 增加缓冲区的大小
+  },
+
+  // 将音频数据编码为PCM格式
+  encodePCM: function () {
+    const bytes = new Float32Array(this.size); // 创建一个新数组,大小为缓冲区大小
+    let offset = 0;
+    for (let i = 0; i < this.buffer.length; i++) {
+      bytes.set(this.buffer[i], offset); // 将缓冲区的数据合并到一个大的 Float32Array 中
+      offset += this.buffer[i].length; // 更新偏移量
+    }
+
+    // 计算编码后的字节长度
+    const dataLength = bytes.length * (this.sampleBits / 8);
+    const buffer = new ArrayBuffer(dataLength); // 创建一个 ArrayBuffer 用来存储最终的音频数据
+    const data = new DataView(buffer); // 使用 DataView 处理二进制数据
+
+    offset = 0;
+    // 将音频数据转换为 PCM 格式
+    for (let i = 0; i < bytes.length; i++, offset += 2) {
+      const s = Math.max(-1, Math.min(1, bytes[i])); // 确保数据在 [-1, 1] 范围内
+      data.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true); // 转换为 PCM 格式(16-bit)
+    }
+
+    // 返回一个包含PCM数据的 Blob 对象
+    return new Blob([data], { type: 'audio/pcm' });
+  }
+});
+
+// Recorder 类,用于管理音频录制
+export class Recorder {
+  constructor(stream) {
+    this.sampleBits = 16; // 音频采样位深
+    this.inputSampleRate = 48000; // 输入采样率
+    this.outputSampleRate = 16000; // 输出采样率
+    this.channelCount = 1; // 单声道(1 个音频通道)
+
+    // 创建 AudioContext,用于音频的处理
+    this.context = new AudioContext();
+
+    // 创建 MediaStreamSource,将媒体流传给该对象以供处理
+    this.audioInput = this.context.createMediaStreamSource(stream);
+
+    // 注册 AudioWorkletProcessor
+    this.context.audioWorklet.addModule('./audio-processor.js').then(() => {
+      // 创建 AudioWorkletNode 实例
+      this.recorder = new AudioWorkletNode(this.context, 'audio-processor');
+
+      // 将音频输入连接到 AudioWorkletNode
+      this.audioInput.connect(this.recorder);
+      this.recorder.connect(this.context.destination);
+
+      // 处理从 AudioWorkletProcessor 发回的数据
+      this.recorder.port.onmessage = (event) => {
+        const resampledData = this.downsampleBuffer(
+          event.data, // 获取音频数据
+          this.inputSampleRate,
+          this.outputSampleRate
+        );
+        this.audioData.input(resampledData); // 将音频数据输入到音频数据对象中
+      };
+    });
+  }
+
+  // 开始录音
+  start() {
+    // 开始录音的逻辑
+    this.audioInput.connect(this.recorder);
+    this.recorder.connect(this.context.destination);
+  }
+
+  // 停止录音
+  stop() {
+    this.recorder?.disconnect(); // 断开连接
+  }
+
+  // 获取录音的 PCM Blob 数据
+  getBlob() {
+    return this.audioData.encodePCM(); // 返回编码后的 PCM 数据
+  }
+
+  // 清除音频数据
+  clear() {
+    this.audioData.clear(); // 清空缓冲区
+  }
+
+  // 缩小音频采样率(即重采样)
+  downsampleBuffer(buffer, inputSampleRate, outputSampleRate) {
+    if (outputSampleRate === inputSampleRate) {
+      return buffer; // 如果输入和输出采样率相同,直接返回原数据
+    }
+
+    // 计算重采样的比率
+    const sampleRateRatio = inputSampleRate / outputSampleRate;
+    const newLength = Math.round(buffer.length / sampleRateRatio); // 计算新数组的长度
+    const result = new Float32Array(newLength); // 创建新的音频数组
+
+    let offsetResult = 0;
+    let offsetBuffer = 0;
+    // 执行重采样
+    while (offsetResult < result.length) {
+      const nextOffsetBuffer = Math.round((offsetResult + 1) * sampleRateRatio); // 计算新的缓冲区位置
+      let accum = 0,
+        count = 0;
+      // 对应区间的音频数据进行平均
+      for (let i = offsetBuffer; i < nextOffsetBuffer && i < buffer.length; i++) {
+        accum += buffer[i];
+        count++;
+      }
+      result[offsetResult] = accum / count; // 计算该位置的平均值
+      offsetResult++;
+      offsetBuffer = nextOffsetBuffer; // 更新缓冲区的位置
+    }
+    return result; // 返回重采样后的数据
+  }
+}

+ 0 - 285
src/modules/meeting-detail/record-detail/record_v2.vue

@@ -1,285 +0,0 @@
-<template>
-  <div>
-    <button @click="toggleRecording" :class="['recordButton', { recording: isRecording }]">
-      {{ isRecording ? 'Stop Recording' : 'Start Recording' }}
-    </button>
-    <label>
-      Language:
-      <input v-model="lang" type="text" value="auto" />
-    </label>
-    <label> <input type="checkbox" v-model="speakerVerification" /> Speaker Verification </label>
-    <hr />
-    Transcription result will be displayed below:
-    <p id="transcriptionResult">{{ transcriptionResult }}</p>
-  </div>
-</template>
-
-<script>
-import { defineComponent, ref, onMounted, onBeforeUnmount } from 'vue';
-
-export default defineComponent({
-  name: 'AudioTranscription',
-  setup() {
-    const lang = ref('auto');
-    const speakerVerification = ref(false);
-    const isRecording = ref(false);
-    const transcriptionResult = ref('');
-    const ws = ref(null);
-    const record = ref(null);
-    const timeInte = ref(null);
-
-    const speakerVerificationParam = computed(() => (speakerVerification.value ? 1 : 0));
-
-    // WebSocket initialization
-    const initWebSocket = () => {
-      const queryParams = [];
-      if (lang.value) queryParams.push(`lang=${lang.value}`);
-      if (speakerVerification.value) queryParams.push('sv=1');
-      const queryString = queryParams.length > 0 ? `?${queryParams.join('&')}` : '';
-
-      const init_send_message_data = {
-        ext: '.wav',
-        meeting_id: 'f282094a415c482e9e72a3c6a94a30d1'
-      };
-
-      ws.value = new WebSocket(`ws://192.168.20.72:9601/api/asr/realtime`);
-      ws.value.binaryType = 'arraybuffer';
-
-      ws.value.onopen = () => {
-        console.log('WebSocket connection established');
-        ws.value.send(JSON.stringify(init_send_message_data));
-        record.value.start();
-        timeInte.value = setInterval(() => {
-          if (ws.value.readyState === 1) {
-            const audioBlob = record.value.getBlob();
-            console.log('Blob size: ', audioBlob.size);
-
-            const reader = new FileReader();
-            reader.onloadend = () => {
-              console.log('Blob content: ', new Uint8Array(reader.result));
-              ws.value.send(audioBlob);
-              console.log('Sending audio data');
-              record.value.clear();
-            };
-            reader.readAsArrayBuffer(audioBlob);
-          }
-        }, 500);
-      };
-
-      ws.value.onmessage = (evt) => {
-        console.log('Received message: ' + evt.data);
-        try {
-          const resJson = JSON.parse(evt.data);
-          if (resJson.code === 0) {
-            transcriptionResult.value += `\n${resJson.data || 'No speech recognized'}`;
-          }
-        } catch (e) {
-          console.error('Failed to parse response data', e);
-          transcriptionResult.value += `\n${evt.data}`;
-        }
-      };
-
-      ws.value.onclose = () => {
-        console.log('WebSocket connection closed');
-      };
-
-      ws.value.onerror = (error) => {
-        console.error('WebSocket error: ' + error);
-      };
-    };
-
-    const startRecording = () => {
-      console.log('Start Recording');
-      if (!navigator.getUserMedia) {
-        alert('Your browser does not support audio input');
-        return;
-      }
-
-      navigator.getUserMedia(
-        { audio: true },
-        (mediaStream) => {
-          initRecorder(mediaStream);
-          initWebSocket();
-        },
-        (error) => {
-          console.error('Error accessing microphone:', error);
-        }
-      );
-    };
-
-    const stopRecording = () => {
-      console.log('Stop Recording');
-      if (ws.value) {
-        ws.value.close();
-      }
-      if (record.value) {
-        record.value.stop();
-        clearInterval(timeInte.value);
-      }
-      isRecording.value = false;
-    };
-
-    const toggleRecording = () => {
-      if (isRecording.value) {
-        stopRecording();
-      } else {
-        startRecording();
-        isRecording.value = true;
-      }
-    };
-
-    const initRecorder = (stream) => {
-      record.value = new Recorder(stream);
-    };
-
-    onMounted(() => {
-      if (!navigator.getUserMedia) {
-        alert('Your browser does not support audio input');
-      }
-    });
-
-    onBeforeUnmount(() => {
-      if (ws.value) {
-        ws.value.close();
-      }
-      if (record.value) {
-        record.value.stop();
-      }
-      if (timeInte.value) {
-        clearInterval(timeInte.value);
-      }
-    });
-
-    // Recorder logic
-    class Recorder {
-      constructor(stream) {
-        this.sampleBits = 16;
-        this.inputSampleRate = 48000;
-        this.outputSampleRate = 16000;
-        this.channelCount = 1;
-        this.context = new AudioContext();
-        this.audioInput = this.context.createMediaStreamSource(stream);
-        this.recorder = this.context.createScriptProcessor(4096, this.channelCount, this.channelCount);
-        this.audioData = {
-          size: 0,
-          buffer: [],
-          inputSampleRate: this.inputSampleRate,
-          inputSampleBits: this.sampleBits,
-          clear() {
-            this.buffer = [];
-            this.size = 0;
-          },
-          input(data) {
-            this.buffer.push(new Float32Array(data));
-            this.size += data.length;
-          },
-          encodePCM() {
-            const bytes = new Float32Array(this.size);
-            let offset = 0;
-
-            // 合并所有数据到一个字节数组
-            for (let i = 0; i < this.buffer.length; i++) {
-              bytes.set(this.buffer[i], offset);
-              offset += this.buffer[i].length;
-            }
-
-            // 计算最终数据需要的字节长度
-            const dataLength = bytes.length * (this.sampleBits / 8);
-            const buffer = new ArrayBuffer(dataLength);
-            const data = new DataView(buffer);
-
-            // 使用新的offset递增逻辑,避免超出边界
-            offset = 0;
-            for (let i = 0; i < bytes.length; i++, offset += 2) {
-              if (offset + 2 > data.byteLength) {
-                console.error('Offset exceeds buffer length');
-                break;
-              }
-
-              const s = Math.max(-1, Math.min(1, bytes[i])); // 限制值的范围
-              data.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true); // 写入16位数据
-            }
-
-            // 返回Blob数据
-            return new Blob([data], { type: 'audio/pcm' });
-          }
-        };
-
-        this.start = () => {
-          this.audioInput.connect(this.recorder);
-          this.recorder.connect(this.context.destination);
-        };
-
-        this.stop = () => {
-          this.recorder.disconnect();
-        };
-
-        this.getBlob = () => {
-          return this.audioData.encodePCM();
-        };
-
-        this.clear = () => {
-          this.audioData.clear();
-        };
-
-        this.recorder.onaudioprocess = (e) => {
-          const resampledData = this.downsampleBuffer(
-            e.inputBuffer.getChannelData(0),
-            this.inputSampleRate,
-            this.outputSampleRate
-          );
-          this.audioData.input(resampledData);
-        };
-      }
-
-      downsampleBuffer(buffer, inputSampleRate, outputSampleRate) {
-        if (outputSampleRate === inputSampleRate) {
-          return buffer;
-        }
-        const sampleRateRatio = inputSampleRate / outputSampleRate;
-        const newLength = Math.round(buffer.length / sampleRateRatio);
-        const result = new Float32Array(newLength);
-        let offsetResult = 0;
-        let offsetBuffer = 0;
-        while (offsetResult < result.length) {
-          const nextOffsetBuffer = Math.round((offsetResult + 1) * sampleRateRatio);
-          let accum = 0,
-            count = 0;
-          for (let i = offsetBuffer; i < nextOffsetBuffer && i < buffer.length; i++) {
-            accum += buffer[i];
-            count++;
-          }
-          result[offsetResult] = accum / count;
-          offsetResult++;
-          offsetBuffer = nextOffsetBuffer;
-        }
-        return result;
-      }
-    }
-
-    return {
-      lang,
-      speakerVerification,
-      transcriptionResult,
-      isRecording,
-      toggleRecording
-    };
-  }
-});
-</script>
-
-<style scoped>
-.recordButton.recording {
-  background-color: red;
-  color: white;
-}
-
-#transcriptionResult {
-  white-space: pre-wrap;
-  background-color: #f5f5f5;
-  padding: 10px;
-  border: 1px solid #ddd;
-  border-radius: 5px;
-  font-family: monospace;
-}
-</style>

+ 59 - 0
src/modules/meeting-detail/top-tools/ModalCloseRecord.vue

@@ -0,0 +1,59 @@
+<template>
+  <a-modal
+    v-model:visible="visible"
+    :simple="true"
+    :hideCancel="false"
+    :modalClass="'arco-modal-delete-simple'"
+    @ok="handleOk"
+    @cancel="handleCancel"
+  >
+    <!-- <template #title> -->
+    <div class="title-box">
+      <!-- <icon-exclamation-circle-fill style="color: #f37500; font-size: 28px" /> -->
+      <span style="font-weight: bold">当前录音正在进行中,是否结束录音?</span>
+    </div>
+    <!-- </template> -->
+  </a-modal>
+</template>
+
+<script setup>
+import { toRefs } from 'vue';
+
+const props = defineProps({
+  visible: Boolean
+});
+
+const { visible } = toRefs(props);
+const emit = defineEmits(['ok', 'cancel']);
+
+const handleOk = () => {
+  emit('ok');
+};
+
+const handleCancel = () => {
+  emit('cancel');
+};
+</script>
+
+<style>
+/* 通过modal方法生成的简单类型弹窗样式,主要用于删除弹窗 */
+.arco-modal-delete-simple {
+  padding: 16px;
+}
+.arco-modal-delete-simple .arco-modal-title-align-center {
+  justify-content: start;
+}
+.arco-modal-delete-simple .arco-modal-footer {
+  margin-top: 16px;
+  text-align: right;
+}
+
+.arco-modal-delete-simple .title-box {
+  display: flex;
+  align-items: center;
+}
+.arco-modal-delete-simple .title-box > span {
+  font-weight: 500;
+  margin-left: 8px;
+}
+</style>

+ 1 - 0
src/modules/meeting-detail/top-tools/SearchAndUpload.vue

@@ -23,6 +23,7 @@ const disabled = computed(() => {
   return useMeeting.disabledInputOrBtn;
 });
 
+// 会议纪要下载
 const downloadSummaryFun = async () => {
   try {
     const res = await downloadSummary({ meeting_id: useMeeting.meet_id });

+ 1 - 0
src/views/Index.vue

@@ -16,6 +16,7 @@ import { useCurrentPageStore } from '../base/store/use-current-page';
 
 const useCurrentPage = useCurrentPageStore();
 
+// 当前页面
 const current_page = computed(() => {
   return useCurrentPage.page;
 });

+ 3 - 0
src/views/home/MenuList.vue

@@ -39,12 +39,15 @@ const menu_list = ref([
   }
 ]);
 
+// 默认选中第一个菜单
 const current_menu = ref(menu_list.value[0].label);
 
+// 切换菜单
 const changeMenu = (item) => {
   current_menu.value = item.label;
   emit('changeMenu', item);
 };
+
 onMounted(() => {
   changeMenu(menu_list.value[0]);
 });

+ 2 - 1
src/views/meeting-detail/index.vue

@@ -71,13 +71,14 @@ const getMeetingDetailFun = async (meeting_id) => {
     const record_detail_data = res.data.view_content;
     useMeeting.clearRecordList();
     record_detail_data.forEach((item) => {
-      useMeeting.pushDataToRecordList(item);
+      useMeeting.pushDataToRecordListItem(item);
     });
     useMeeting.setCurrentMeetingData(top_tool_data);
     audio_player_url.value = res.data.file_url;
   }
   return res;
 };
+// 结束录音
 const closeRecord = async () => {
   isLoading.value = true;
   useMeeting.changeMeetType('editMeet');

+ 3 - 1
src/views/routes.js

@@ -5,7 +5,9 @@ const routes = [
   { path: '/', redirect: '/home' },
 
   // 主页
-  { path: '/home', component: () => import('./Index.vue') }
+  { path: '/home', component: () => import('./Index.vue') },
+  // 会议详情
+  { path: '/detail', component: () => import('./meeting-detail/Index.vue') }
 ];
 
 export default routes;

+ 1 - 0
vite.config.mjs

@@ -23,6 +23,7 @@ export default defineConfig({
       '/api/': {
         // target: 'http://192.168.20.119:9601',
         target: 'http://192.168.20.72:9601',
+        // target: 'https://192.168.20.119:26100',
         changeOrigin: true,
         secure: false,
         ws: true,

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.