思路:

1.调用web api navigator.mediaDevices.getUserMedia获取音频权限
2.链接websocket

主要问题 navigator.mediaDevices.getUserMedia 在web中能主动获取麦克风权限,但是安卓webview无法获取权限。 需要原生写一些相关授权,注意需要静默授权才能获取权限

完整代码

<template>
  <div v-btnAuth="authCode.tab" class="box flex-su ipad-top">
    <Header title="AI会议录制" theme="custom"></Header>

    <!-- <div>{{ isConnected ? '已连接' : '未连接' }}</div> -->
    <div class="main flex-su-order-1">
      <nut-row class="ht100 block">
        <nut-col :span="15" class="ht100">
          <div class="left-main flex-su ht100">
            <div class="flex-su-order-1 flex-su pdl20 pdt20 pdb10">
              <div class="f3 font-weight500 mgb24">
                <span class="curDate">{{ info.curDate }}</span> 记录
              </div>
              <div id="mainBox" class="flex-su-order-1 pdr20">
                <div v-for="(item, index) in info.list" :key="index">
                  <template v-if="item.speakName">
                    <div class="flex-heng-between flex-align-center">
                      <div class="flex">
                        <img class="taking" src="@/assets/ipad/taking.png" />
                        <div class="f0 mgl5 mgr10 c6">{{ item.speakName }}</div>
                        <div class="f0 c6" v-if="item.isComplete">
                          {{ getTimeMins(item.recordingDurationTime, false) }}
                        </div>
                      </div>

                      <!-- 假的占位 -->
                      <div class="signBox op" v-if="!item.isComplete">
                        <img class="taking" src="@/assets/ipad/toDoLo.png" />
                      </div>
                      <div class="signBox" v-if="item.isComplete">
                        <img
                          class="taking"
                          v-show="item.focusFlag === 0"
                          @click="editSign(item, 'focusFlag', 1)"
                          src="@/assets/ipad/toDoLo.png"
                        />
                        <img
                          v-show="item.focusFlag !== 0"
                          @click="editSign(item, 'focusFlag', 0)"
                          class="taking"
                          src="@/assets/ipad/toDoLoActive.png"
                        />
                        <img
                          v-show="item.questFlag === 0"
                          class="taking"
                          @click="editSign(item, 'questFlag', 1)"
                          src="@/assets/ipad/problem.png"
                        />
                        <img
                          v-show="item.questFlag !== 0"
                          @click="editSign(item, 'questFlag', 0)"
                          class="taking"
                          src="@/assets/ipad/problemActive.png"
                        />
                        <img
                          v-show="item.waitToDoFlag === 0"
                          @click="editSign(item, 'waitToDoFlag', 1)"
                          class="taking"
                          src="@/assets/ipad/sign.png"
                        />
                        <img
                          v-show="item.waitToDoFlag !== 0"
                          @click="editSign(item, 'waitToDoFlag', 0)"
                          class="taking"
                          src="@/assets/ipad/signActive.png"
                        />
                      </div>
                    </div>
                    <div class="mgl27 taking-text">
                      {{ item.contentText }}
                    </div>
                  </template>
                </div>
              </div>
            </div>

            <div class="btn-box flex-heng-between flex-align-center">
              <div class="flex">
                <img
                  v-if="isRecording && !isEnd"
                  class="vedioMp3"
                  src="@/assets/ipad/vedioStart.gif"
                />
                <img v-else class="vedioMp3" src="@/assets/ipad/vedioMp3.png" />
                <ul>
                  <li class="f2 mgb2 font-weight500">
                    {{
                      isEnd
                        ? '录音结束'
                        : isRecording
                        ? '正在录音...'
                        : '暂停录音'
                    }}
                  </li>
                  <li class="f0 c9 font-weight500">
                    {{
                      getTimeMins(
                        route.query.meetId
                          ? curMeetTime - stopMeetTime + initMeetTime
                          : curMeetTime - stopMeetTime,
                        true
                      )
                    }}
                  </li>
                </ul>
              </div>
              <div class="flex">
                <div
                  class="start-btn"
                  @click="stopBtnClick"
                  v-if="isRecording && !isEnd"
                >
                  <img src="@/assets/ipad/stop.png" />
                </div>
                <div
                  v-if="!isRecording && !isEnd"
                  class="start-btn"
                  @click="startBtnClick"
                >
                  <img class="start" src="@/assets/ipad/start.svg" />
                </div>
                <div v-if="!isEnd" class="stop-btn f2" @click="opendEndAudio">
                  结束
                </div>
              </div>
            </div>
          </div>
        </nut-col>
        <nut-col :span="9" class="ht100">
          <div class="right-main flex-su">
            <div class="flex-heng mgb16">
              <div class="active f2 mgr38">标记内容</div>
              <!-- <div class="c9 f2">思维导图</div> -->
            </div>
            <nut-popover v-model:visible="show" location="bottom">
              <template #reference>
                <div class="c9">提取标记类型 <RectDown /></div>
              </template>

              <template #content>
                <div class="down-data">
                  <div
                    class="flex-heng-between mgb20 flex-align-center"
                    @click="changeSign(1)"
                  >
                    <div>
                      <img
                        class="toDoLoSmall"
                        src="@/assets/ipad/toDoLoSmall.png"
                      />
                      <span class="f2">重点标记</span>
                    </div>
                    <Check v-if="rightActive.includes(1)" color="#4070ff" />
                  </div>
                  <div
                    class="flex-heng-between mgb20 flex-align-center"
                    @click="changeSign(2)"
                  >
                    <div>
                      <img
                        class="problemSmall"
                        src="@/assets/ipad/problemSmall.png"
                      />
                      <span class="f2">问题标记</span>
                    </div>
                    <Check v-if="rightActive.includes(2)" color="#4070ff" />
                  </div>
                  <div
                    class="flex-heng-between flex-align-center"
                    @click="changeSign(3)"
                  >
                    <div>
                      <img
                        class="signSmall"
                        src="@/assets/ipad/signSmall.png"
                      />
                      <span class="f2">重点标记</span>
                    </div>
                    <Check v-if="rightActive.includes(3)" color="#4070ff" />
                  </div>
                </div>
              </template>
            </nut-popover>

            <div class="mgt20 pdr20 flex-su-order-1">
              <div
                v-for="(item, index) in info.list.filter(
                  (ele) => ele.focusFlag || ele.questFlag || ele.waitToDoFlag
                )"
                :key="index"
              >
                <template
                  v-if="
                    (item.focusFlag && rightActive.includes(1)) ||
                    (item.questFlag && rightActive.includes(2)) ||
                    (item.waitToDoFlag && rightActive.includes(3))
                  "
                >
                  <div>
                    <img
                      v-if="item.focusFlag"
                      class="toDoLoSmall"
                      src="@/assets/ipad/toDoLoSmall.png"
                    />
                    <img
                      v-if="item.questFlag"
                      class="problemSmall"
                      src="@/assets/ipad/problemSmall.png"
                    />
                    <img
                      v-if="item.waitToDoFlag"
                      class="signSmall"
                      src="@/assets/ipad/signSmall.png"
                    />
                    <span class="f2">标记</span>
                    <span v-if="item.focusFlag" class="f0 dib mgl5">重点</span>
                    <span v-if="item.questFlag" class="f0 dib mgl5">问题</span>
                    <span v-if="item.waitToDoFlag" class="f0 dib mgl5"
                      >待办</span
                    >
                  </div>
                  <div class="mgl22 mgb20 mgt8">
                    {{ item.contentText }}
                  </div>
                </template>
              </div>
            </div>

            <!-- <div class="btn-right-box" @click="baseClick">AI生成待办</div> -->
          </div>
        </nut-col>
      </nut-row>

      <nut-dialog
        key="1"
        teleport="#app"
        title="温馨提醒"
        ok-text="确定"
        content="当前录制时间最长3小时,还剩最后10分钟,请注意时间一到会自动结束录制!!是否现在结束会议?"
        v-model:visible="endTimeVisible"
        @ok="endAudio"
      >
      </nut-dialog>

      <nut-dialog
        key="2"
        teleport="#app"
        title="111"
        ok-text="确定"
        content="确定要结束录制吗?"
        v-model:visible="endVisible"
        @ok="endAudio"
      >
        <template #header>
          <div>
            <img
              class="w20 vaf4"
              src="@/assets/ipad/warning.svg"
              alt=""
              srcset=""
            />
            <span v-if="isRecording">当前会议正在录制中</span>
            <span v-else>当前会议正在暂停中</span>
          </div>
        </template>
      </nut-dialog>
    </div>
  </div>
</template>

<script setup lang="ts">
import {
  authCode,
  API,
  InfoFaceTranscribe,
  TakingFace,
  getTimeMins,
} from './indexMods/configure.js';
import { showToast, showDialog } from '@nutui/nutui';
import moment from 'moment';
defineOptions({
  name: 'ipadHomeIndex',
});

/*-----------------------------公共-------------------------------------------*/
const store = useStore();
const route = useRoute();
const router = useRouter();
const info = reactive<InfoFaceTranscribe>({
  list: [],
  curDate: moment().format('YYYY-MM-DD HH:mm:ss'),
});
let startMeetTime = ''; //会议开始时间
let curMeetTime = ref(0); //会议的时长 curMeetTime - stopMeetTime 录音的时长
let stopMeetTime = ref(0); // 录音暂停了多少时间
let initMeetTime = 0; //初始的录音时长 例如下次进来的时长

let oneTip = 0;
let timer = setInterval(() => {
  info.curDate = moment(new Date(info.curDate).getTime() + 1000).format(
    'YYYY-MM-DD HH:mm:ss'
  );
  //实时计算会议时间
  curMeetTime.value =
    (new Date(info.curDate).getTime() - new Date(startMeetTime).getTime()) /
    1000;

  if (!isRecording.value) {
    // 暂停
    stopMeetTime.value = stopMeetTime.value + 1;
  }

  // 超过3小时结束会议
  if (curMeetTime.value > 10200 && oneTip === 0) {
    oneTip++;
    opendTimeEndAudio();
  }
  if (curMeetTime.value > 10800) {
    endAudio();
  }
}, 1000);

const show = ref(false);

/*------------------------------------初始化1.获取会议信息 2.新建会议并同时连接websocket------------------------------------*/
let meetId = '';
const init = async () => {
  // requestMicrophonePermission();
  //
  console.log('开始获取录音权限');

  try {
    await startRecording();
  } catch (error) {
    console.log(error);
    return false;
  }
  // 防止刷新页面 热更新 反复创建 会议
  let storageData: any = localStorage.getItem('meetBox');
  if (route.query.meetId) {
    // 说明是继续会议 进来的
    meetId = route.query.meetId as string;
    websocketInfo.value = {
      MeetingJoinUrl: route.query.externalWebsocketUrl,
      TaskId: route.query.taskId,
    };
    startMeetTime = moment().format('YYYY-MM-DD HH:mm:ss') as string;
    initMeetTime = Number(route.query.initMeetTime);

    await doHistory();
    return false;
  } else if (storageData) {
    storageData = JSON.parse(storageData);
    websocketInfo.value = storageData.taskInfo;
    meetId = storageData.meetId;
    startMeetTime = storageData.startMeetTime;
    stopMeetTime.value = storageData.stopMeetTime;

    await doHistory();
    return false;
  }

  requestAi({
    // mock: true,
    url: API.taskAiCreate,
    data: {
      ignoreRepeat: true,
      task_key: new Date().getTime(),
    },
  }).then((res) => {
    websocketInfo.value = res;
    // 调用景天新建
    startMeetTime = info.curDate;
    addMeet().then((two) => {
      localStorage.setItem(
        'meetBox',
        JSON.stringify({
          meetId: two,
          taskInfo: res,
          startMeetTime: startMeetTime,
          stopMeetTime: stopMeetTime.value,
        })
      );
      meetId = two;
      connectWebSocket(websocketInfo.value);
    });
  });
};

const doHistory = async () => {
  // 获取历史明细200 记录总差值
  let res = await getOnceMeetDetail();
  let num = res.total - info.list.length;
  info.list = [];
  for (let i = 0; i < num; i++) {
    // 添加假数据
    info.list.push({
      recordingDurationTime: 1, //录音时长,单位:秒
      isComplete: true, //是否一句话完成
      contentText: '',
      speakName: '', //发言人
      sort: 0,
      focusFlag: 0, //重点标记标识:1 是重点;0 否
      questFlag: 0, //问题标记标识:1 是; 0 否
      waitToDoFlag: 0, //待办标识:1 是;0 否
    });
  }
  let arr = res.rows.reverse();
  arr.forEach((ele) => {
    ele.isComplete = true;
  });
  info.list = info.list.concat(arr);
  startIndex = info.list.length;

  scrollToBottom(mainBox);

  connectWebSocket(websocketInfo.value);
};

// 一次性获取景天100条会议详情
const getOnceMeetDetail = async () => {
  return requestOmp({
    url: API.detailList,
    data: {
      apiNum: 4,
      ignoreRepeat: true,
      pageQry: {
        meetingSummaryId: meetId,
        order: 'desc',
        sort: 'sort',
        page: 1,
        size: 200,
      },
    },
  });
};

/*-----------------------------------会议的新增 编辑 结束-------------------------------------*/
// 新增会议
const addMeet = async () => {
  return requestOmp({
    url: API.add,
    data: {
      apiNum: 4,
      addCmd: {
        groupCode: route.query.type,
        startTime: startMeetTime,
        externalWebsocketUrl: websocketInfo.value.MeetingJoinUrl,
        externalMeetingFile: '',
        externalMeetingId: websocketInfo.value.TaskId,
      },
    },
  });
};

// 新增会议明细
const addMeetDetail = async (val: TakingFace) => {
  requestOmp({
    url: API.addDetail,
    data: {
      apiNum: 4,
      ignoreRepeat: true,
      addCmd: {
        ...val,
        meetingSummaryId: meetId,
      },
    },
  }).then((res) => {
    val.id = res;
  });
};

// 修改会议想去
const editMeet = async () => {
  requestOmp({
    url: API.edit,
    data: {
      apiNum: 4,
      ignoreRepeat: true,
      updateCmd: {
        id: meetId,
        totalTime: curMeetTime.value - stopMeetTime.value + initMeetTime,
      },
    },
  });
};

// 修改会议明细
const editSign = (val: TakingFace, str: string, num: number): void => {
  if (isEnd.value) {
    showToast.hide();
    showToast.text('当前录音已结束。', {
      'custom-class': 'ipad-toast',
    });
    return;
  }
  val[str] = num;
  editMeetDetail(val);
};
const editMeetDetail = async (val: TakingFace) => {
  requestOmp({
    url: API.editDetail,
    data: {
      apiNum: 4,
      ignoreRepeat: true,
      updateCmd: {
        ...val,
        meetingSummaryId: meetId,
      },
    },
  }).then(() => {});
};

// 结束会议明细
const endMeetOmp = async (val: any) => {
  return requestOmp({
    url: API.finish,
    data: {
      apiNum: 4,
      ignoreRepeat: true,
      finishCmd: {
        id: meetId,
        endTime: moment(new Date(info.curDate).getTime()).format(
          'YYYY-MM-DD HH:mm:ss'
        ),
        totalTime: curMeetTime.value - stopMeetTime.value + initMeetTime,
        // totalTime: curMeetTime.value - stopMeetTime.value,
        externalMeetingId: val.TaskId,
        externalMeetingFile: val.OutputMp3Path, //文件地址
        keywordWorks: val.Result?.MeetingAssistance?.MeetingAssistance?.Keywords
          ? JSON.stringify(
              val.Result.MeetingAssistance.MeetingAssistance.Keywords
            )
          : '', //关键字
        fullSummary: val.Result?.Summarization?.Summarization?.ParagraphSummary, //全文概要
        importantClassif: val.Result?.MeetingAssistance?.MeetingAssistance
          ?.KeySentences
          ? JSON.stringify(
              val.Result.MeetingAssistance.MeetingAssistance.KeySentences
            )
          : '', // 要点归类
        summarySpeech: val.Result?.Summarization?.Summarization
          ?.ConversationalSummary
          ? JSON.stringify(
              val.Result.Summarization.Summarization.ConversationalSummary
            )
          : '', //发言总结
        mindMap: val.Result?.Summarization?.Summarization?.MindMapSummary
          ? JSON.stringify(
              val.Result.Summarization.Summarization.MindMapSummary
            )
          : '', //思维导图
      },
    },
  });
};

//跟ai交互 停止会议任务
const endMeetAi = async () => {
  return requestAi({
    url: API.stopTask,
    data: {
      ignoreRepeat: true,
      task_id: websocketInfo.value.TaskId,
      task_key: new Date().getTime(),
    },
  });
};

//ai总结
const sumMeetAi = async () => {
  return requestAi({
    url: API.taskInfo,
    method: 'get',
    data: {
      ignoreRepeat: true,
      task_id: websocketInfo.value.TaskId,
    },
  });
};

/*----------------------------------连接websocket 初始化录音 获取录音返回--------------------------------------*/
// 连接websocket
const wsRef = ref<any>(null);
const isConnected = ref<boolean>(false);
const isRecording = ref<boolean>(false);
const prevIsRecordingRef = ref<boolean>(false);
const recorderRef = ref<any>(null);
const streamRef = ref<any>(null);
const isClosingRef = ref<boolean>(false);
const websocketInfo = ref<any>({});

// 监听isRecording属性变化,自动控制录音
watch(isRecording, (newValue, oldValue) => {
  // 避免初始渲染时触发
  if (prevIsRecordingRef.value !== isRecording.value) {
    if (isRecording.value) {
      if (!isConnected.value) {
        connectWebSocket(websocketInfo.value);
      } else {
        startRecording();
      }
    } else {
      stopRecording();
    }
  }
  prevIsRecordingRef.value = isRecording.value;
});
// 开始连接
let startIndex = 0;
let startIndexNew = 0;
const connectWebSocket = (val) => {
  // 获取认证 token
  const token = store.getters['user/getToken'];
  // 创建 WebSocket 连接
  try {
    // 这里使用项目中的 WebSocket URL
    const wsUrl = val.MeetingJoinUrl;

    wsRef.value = new WebSocket(wsUrl);

    wsRef.value.binaryType = 'arraybuffer';

    wsRef.value.onopen = () => {
      console.log('WebSocket 连接已打开');
      isConnected.value = true;
      // startRecording();

      // 发送开始转写请求
      const params = {
        header: {
          name: 'StartTranscription',
          namespace: 'SpeechTranscriber',
        },
        payload: {
          format: 'pcm',
          sample_rate: 16000,
          channels: 2,
          bits: 16,
        },
      };
      wsRef.value?.send(JSON.stringify(params));
    };

    wsRef.value.onmessage = (msg) => {
      if (typeof msg.data === 'string') {
        try {
          const dataJson = JSON.parse(msg.data);
          let zhongBox: any = {};
          let endBox: any = {};
          switch (dataJson.header.name) {
            case 'SentenceBegin': {
              startIndexNew = startIndex;
              // 句子开始事件
              // start = {
              //   sentenceId: dataJson.payload.index,
              //   text: dataJson.payload.result,
              //   confidence: dataJson.payload.confidence,
              //   startTime: dataJson.payload.begin_time,
              //   endTime: dataJson.payload.time,
              // };
              // console.log('句子开始事件', start.text, start);
              break;
            }
            case 'TranscriptionResultChanged': {
              // 句中识别结果变化事件
              zhongBox = {
                // sentenceId: dataJson.payload.index,
                // text: dataJson.payload.result,
                // confidence: dataJson.payload.confidence,
                // startTime: dataJson.payload.begin_time,
                // endTime: dataJson.payload.time,
                recordingDurationTime:
                  (dataJson.payload.time - dataJson.payload.begin_time) / 1000, //录音时长,单位:秒
                speakName: '发言人', //发言人
                isComplete: false,
                contentText: dataJson.payload.result,
                sort: Math.round(startIndexNew + dataJson.payload.index), //内容排序
                focusFlag: 0, //重点标记标识:1 是重点;0 否
                questFlag: 0, //问题标记标识:1 是; 0 否
                waitToDoFlag: 0, //待办标识:1 是;0 否
              };
              console.log(
                '句子中间结果',
                startIndexNew + dataJson.payload.index,
                zhongBox,
                dataJson.payload
              );
              info.list[Math.round(startIndexNew + dataJson.payload.index)] =
                zhongBox as TakingFace;
              scrollToBottom(mainBox);
              // if (!isRecording.value) {
              //   startIndex = info.list.length;
              // }
              break;
            }
            case 'SentenceEnd':
              endBox = {
                recordingDurationTime:
                  (dataJson.payload.time - dataJson.payload.begin_time) / 1000, //录音时长,单位:秒
                speakName: '发言人', //发言人
                isComplete: true,
                contentText:
                  dataJson.payload.result + dataJson.payload.stash_result.text,
                sort: Math.round(startIndexNew + dataJson.payload.index), //内容排序
                focusFlag: 0, //重点标记标识:1 是重点;0 否
                questFlag: 0, //问题标记标识:1 是; 0 否
                waitToDoFlag: 0, //待办标识:1 是;0 否
              };
              info.list[Math.round(startIndexNew + dataJson.payload.index)] =
                endBox as TakingFace;
              console.log(
                '结束句子结果',
                startIndexNew + dataJson.payload.index,
                endBox,
                dataJson.payload
              );
              // 结束 新增会议明细
              addMeetDetail(
                info.list[Math.round(startIndexNew + dataJson.payload.index)]
              );
              // 结束 实时记录下录音时长
              editMeet();
              // if (onTranscriptionResult) {
              //   onTranscriptionResult(result);
              // }
              break;
            case 'TaskFailed':
              console.log(`任务失败: ${dataJson.header.status_text}`);
              showToast.fail(`任务失败: ${dataJson.header.status_text}`, {
                'custom-class': 'ipad-toast',
              });
              stopRecording();
              break;
          }
        } catch (err) {
          console.error('解析消息失败:', err);
          showToast.fail('解析消息失败', {
            'custom-class': 'ipad-toast',
          });
        }
      }
    };

    wsRef.value.onerror = (err) => {
      console.error('WebSocket 错误:', err);
      const error = new Error('WebSocket 连接错误');
      showToast.fail('WebSocket 连接错误', {
        'custom-class': 'ipad-toast',
      });
      stopRecording();
    };

    wsRef.value.onclose = () => {
      console.log('WebSocket 连接已关闭');
      isConnected.value = false;
      isClosingRef.value = false;
    };
  } catch (err) {
    console.log('获取麦克风权限失败', err);
    showToast.fail('获取麦克风权限失败', {
      'custom-class': 'ipad-toast',
    });
  }
};

// 开始录音
const startRecording = () => {
  return new Promise((resolve, reject) => {
    console.log('isRecording.value', isRecording.value);

    if (isRecording.value) return;
    // 检查浏览器是否支持
    if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
      reject(new Error('浏览器不支持录音功能'));
    }

    console.log(
      '浏览器支持',
      !(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia),
      navigator.mediaDevices.getUserMedia
    );

    navigator.mediaDevices
      .getUserMedia({ audio: true })
      .then((stream) => {
        recorderRef.value = initRecorder(stream);
        recorderRef.value.start();
        isRecording.value = true;
        showToast.success('录音已开始', {
          'custom-class': 'ipad-toast',
        });
        resolve(stream);
      })
      .catch((error) => {
        console.log('获取麦克风权限失败', error);
        // showToast.fail('获取麦克风权限失败', {
        //   'custom-class': 'ipad-toast',
        // // })
        reject(new Error('web提示获取麦克风权限失败'));
      });
  });
};

// 初始化录音机
const initRecorder = (stream: any) => {
  streamRef.value = stream;
  const context = new window.AudioContext();
  const audioInput = context.createMediaStreamSource(stream);
  const recorder = context.createScriptProcessor(4096, 1, 1);

  const audioData = {
    size: 0,
    buffer: [],
    inputSampleRate: context.sampleRate,
    inputSampleBits: 16,
    outputSampleRate: 16000,
    oututSampleBits: 16,
    clear: function () {
      this.buffer = [];
      this.size = 0;
    },
    input: function (data) {
      this.buffer.push(new Float32Array(data));
      this.size += data.length;
    },
    compress: function () {
      // 合并所有缓冲区数据
      const data = new Float32Array(this.size);
      let offset = 0;
      for (let i = 0; i < this.buffer.length; i++) {
        data.set(this.buffer[i], offset);
        offset += this.buffer[i].length;
      }

      // 计算压缩比
      const compression = this.inputSampleRate / this.outputSampleRate;
      const length = Math.floor(data.length / compression);
      const result = new Float32Array(length);

      // 简单的低通滤波和重采样
      const filter = (x: number) => x; // 实际应用中可使用更复杂的滤波器

      for (let i = 0; i < length; i++) {
        const srcIndex = Math.floor(i * compression);
        result[i] = filter(data[srcIndex]);
      }

      return result;
    },
    encodePCM: function () {
      const sampleRate = this.outputSampleRate;
      const sampleBits = this.oututSampleBits;
      const bytes = this.compress();
      const dataLength = bytes.length * (sampleBits / 8);
      const buffer = new ArrayBuffer(dataLength);
      const data = new DataView(buffer);
      let offset = 0;

      // 正确处理16位PCM编码
      for (let i = 0; i < bytes.length; i++, offset += 2) {
        const s = Math.max(-1, Math.min(1, bytes[i]));
        // 转换为16位有符号整数,小端字节序
        data.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
      }

      return new Blob([data]);
    },
  };

  const sendAudioData = () => {
    // 检查WebSocket状态
    if (!wsRef.value || wsRef.value.readyState !== WebSocket.OPEN) {
      console.warn('WebSocket is not open, skipping audio data send');
      audioData.clear();
      return;
    }

    // 对数据进行分片处理
    const reader = new FileReader();
    reader.onload = (e) => {
      if (!e.target?.result) return;

      const outbuffer = e.target.result as ArrayBuffer;
      const arr = new Int8Array(outbuffer);

      if (arr.length > 0) {
        // 使用4096字节的分片大小
        const chunkSize = 4096;
        for (let start = 0; start < arr.length; start += chunkSize) {
          const end = Math.min(start + chunkSize, arr.length);
          const chunk = arr.slice(start, end);

          if (wsRef.value?.readyState === WebSocket.OPEN) {
            wsRef.value.send(chunk);
          } else {
            console.warn('WebSocket closed during data send');
            break;
          }
        }
      }
    };

    reader.readAsArrayBuffer(audioData.encodePCM());
    audioData.clear();
  };

  recorder.onaudioprocess = (e) => {
    // 如果连接正在关闭,则不处理新的音频数据
    if (isClosingRef.value) return;
    const inputBuffer = e.inputBuffer.getChannelData(0);
    audioData.input(inputBuffer);
    sendAudioData();
  };

  return {
    start: () => {
      audioInput.connect(recorder);
      recorder.connect(context.destination);
    },
    stop: () => {
      recorder.disconnect();
    },
    clear: () => {
      audioData.clear();
    },
  };
};

// 停止录音
const stopRecording = () => {
  if (!isRecording.value) return;

  // 停止录音
  if (recorderRef.value) {
    recorderRef.value.stop();
    recorderRef.value = null;
  }

  // 停止媒体流
  if (streamRef.value) {
    streamRef.value.getTracks().forEach((track) => track.stop());
    streamRef.value = null;
  }

  // 关闭WebSocket连接
  if (wsRef.value && wsRef.value.readyState === WebSocket.OPEN) {
    // isClosingRef.value = true;
    // 发送停止转写请求
    const params = {
      header: {
        name: 'StopTranscription',
        namespace: 'SpeechTranscriber',
      },
      payload: {},
    };
    wsRef.value.send(JSON.stringify(params));
    // // 等待5秒后关闭连接
    const timer = setTimeout(() => {
      // if (wsRef.value) {
      //   wsRef.value.close();
      //   wsRef.value = null;
      // }
      startIndex = info.list.length;
      info.list[info.list.length - 1].isComplete = true;
      clearTimeout(timer);
    }, 1000);
  }

  isRecording.value = false;
  showToast.success('录音已停止', {
    duration: 2000,
    cover: true,
    'custom-class': 'ipad-toast',
  });
};

// 方法2:使用 scrollTo() 方法(更简洁)
const scrollToBottom = (element: HTMLElement) => {
  if (!element) return;
  // console.log(element, element.scrollHeight, element.clientHeight);

  // 滚动到底部(scrollHeight 是内容总高度,clientHeight 是可视区域高度)
  element.scrollTop = element.scrollHeight - element.clientHeight;
};

const stopBtnClick = () => {
  stopRecording();
};

const startBtnClick = () => {
  startRecording();
};
/**
 * @description: 结束
 * @return {*}
 */
const endVisible = ref(false);
const endTimeVisible = ref(false);
const isEnd = ref<boolean>(false); //结束
const opendEndAudio = () => {
  endVisible.value = true;
};
const opendTimeEndAudio = () => {
  endTimeVisible.value = true;
};
const endAudio = async () => {
  isEnd.value = true;
  stopRecording();
  showToast.hide();
  showToast.loading('保存内容中', {
    duration: 2000,
    cover: true,
    'custom-class': 'ipad-toast',
  });
  // ai 停止会议
  await endMeetAi();

  // ai总结
  let two = await sumMeetAi();

  // 景天结束会议
  clearInterval(timer);
  await endMeetOmp(two);
  showToast.success('保存成功', {
    'custom-class': 'ipad-toast',
  });

  localStorage.removeItem('meetBox');

  let go = setTimeout(() => {
    router.replace({
      path: '/ipad/meeting/summarizeRouter',
      query: {
        meetId: meetId,
      },
    });
    clearTimeout(go);
  }, 1000);

  // try {
  //   // ai总结
  //   let two = await sumMeetAi();
  //   showToast.success('AI总结成功');
  //   // 信息保存到景天
  //   console.log('two', two);
  //   // await editMeet()
  // } catch (error) {
  //   showToast.fail('AI总结失败,请到记录详情中重试');
  // }

  //
};

/*---------------------------------右边交互---------------------------------------*/
const rightActive = ref<number[]>([1, 2, 3]);
const changeSign = (val: number) => {
  let index = rightActive.value.indexOf(val);
  if (index === -1) {
    rightActive.value.push(val);
  } else {
    rightActive.value.splice(index, 1);
  }
};

/*------------------------------------------------------------------------*/
// 路由导航守卫:处理路由跳转时的提醒
const removeRouteGuard = router.beforeEach((to, from, next) => {
  // 开始录音 未结束
  if (isRecording.value && !isEnd.value) {
    showDialog({
      title: '温馨提醒',
      content: '会议尚未结束,确定要结束会议并离开吗?',
      onOk: () => {
        endAudio();
      },
      onCancel: () => {
        next(false); // 取消离开
      },
    });
  } else {
    next(); // 已保存,直接离开
  }
});

// 浏览器窗口关闭/刷新时的提醒
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
  if (isRecording.value && !isEnd.value) {
    // 标准浏览器会显示默认提示语,自定义文字可能不生效
    e.preventDefault();
    e.returnValue = '会议尚未结束,确定要离开吗?';
    return e.returnValue;
  }
};

// 注册事件监听
window.addEventListener('beforeunload', handleBeforeUnload);
//----------------------------------------------------------
onBeforeMount(() => {});

let mainBox: HTMLElement = null;
onMounted(() => {
  mainBox = document.getElementById('mainBox');
  init();
});

// 组件卸载时清理资源
onDeactivated(() => {
  // 移除路由守卫
  removeRouteGuard();
  // 移除窗口事件监听
  window.removeEventListener('beforeunload', handleBeforeUnload);
  stopRecording();
  clearInterval(timer);
});
</script>

<style lang="scss" scoped>
.box {
  background: #dce9fe;
}
.main {
  margin: 10px 40px 20px 40px;
  background: #fff;
  border-radius: 7px;
}
.taking {
  width: 27px;
}
.signBox {
  img {
    width: 32px;
    margin-left: 10px;
    margin-bottom: 6px;
  }
}
.left-main {
  border-right: 1px solid #eff0f1;
}
.taking-text {
  background: #f9f9f9;
  padding: 6px 9px;
  display: inline-block;
  margin-bottom: 10px;
  border-radius: 4px;
}
.btn-box {
  background: #ffffff;
  box-shadow: 0px -1px 10px 0px rgba(0, 0, 0, 0.06);
  padding: 12px 28px;
}
.vedioMp3 {
  width: 31px;
  margin-right: 16px;
}
.stop-btn {
  width: 75px;
  height: 41px;
  background: #dbe4ff;
  border-radius: 42px;
  text-align: center;
  line-height: 42px;
}

.start-btn {
  width: 75px;
  height: 41px;
  background: #4070ff;
  border-radius: 42px;
  text-align: center;
  margin-right: 10px;
  img {
    width: 14px;
    margin-top: 13.5px;
  }
  .start {
    width: 10px;
    margin-left: 5px;
  }
  :deep(.nut-icon) {
    width: 40px !important;
  }
}

.right-main {
  padding: 20px 0px 12px 24px;
}
.active {
  font-size: $c3;
  font-weight: 500;
  padding: 0 0 4px 0;
  border-bottom: 3px solid #487ffb;
}
:deep(.nut-icon) {
  width: 10px;
  vertical-align: -2px;
  margin-left: 2px;
}
.down-data {
  width: 213px;
  height: 160px;
  background: #ffffff;
  box-shadow: 0px 2px 12px 0px rgba(0, 0, 0, 0.12);
  border-radius: 8px;
  padding: 24px 17px;
  :deep(.nut-icon) {
    width: 16px;
    vertical-align: -2px;
    margin-left: 2px;
  }
}
.toDoLoSmall {
  width: 11px;
  vertical-align: -3px;
  margin-left: 2px;
  margin-right: 9px;
}
.problemSmall {
  width: 15px;
  vertical-align: -1px;
  margin-right: 8px;
}

.signSmall {
  width: 14px;
  vertical-align: -1px;
  margin-right: 8px;
}
.btn-right-box {
  width: 90%;
  height: 41px;
  background: #4070ff;
  border-radius: 100px;
  font-weight: 600;
  font-size: 18px;
  color: #ffffff;
  line-height: 41px;
  text-align: center;
}

:deep(.nut-dialog__footer .nut-button .nut-button__wrap) {
  line-height: 32px !important;
}
.curDate {
  display: inline-block;
  width: 190px;
}
</style>

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐