使用声网和腾讯云speech sdk实现会议白板

933 阅读4分钟

前言

大家好,这是我第一篇文章,以后也会利用掘金做工作内容记录,学习笔记以及一些技术的分享。我在前端方面也是初出茅庐,如果在内容中有任何错误请大家指正,感谢!

正言

今天我要分享的内容为使用声网和腾讯云speech sdk实现会议白板,其大致的功能分为两端:

会议端:实现语音会议室,并将语音实时转换成文字,同时保存每轮对话的语音音频;

白板端:第一步:根据语音修正机器识别不正确的文字;第二步:为会议内容打标签,摘取关键字(包括主题、时间、人物、内容),形成taskJson,为机器学习提供物料;

会议端准备工作及实现

根据上述内容不难看出这个需求需要的三方工具为声网websdk以及腾讯云语音识别sdk,接下来我先简单介绍一下这两个工具。

声网agora

声网主要用来实现会议室的语音房功能,声网的文档写的非常详细,且有demo可以参考,我就不多赘述了,有几点我提醒一下:

  1. 咱们想直接跑demo直接使用临时token就可以尝试,但是实际应用中需要服务端来返回token;
  2. demo只支持localhost或者可以外网访问的服务器,不可以用本地ip;
  3. 加入房间的参数uid的类型为number,在项目中我们很有可能把一些info存在store或者浏览器缓存里,存取之中很有可能类型变成string,string类型可能会造成语音房功能异常,大家一定要注意!!!

腾讯云语音识别sdk

腾讯云语音识别sdk主要用来实现语音转文字的功能,websdk同样有js版本的demo可以供大家参考,sdk引入会暴露出WebAudioSpeechRecognizerSpeechRecognizer对象,前后两者的区别是前者是使用内置录音,后者可以自定义数据源,我们这里只简单用到了前者,初始化的参数大家可以参考文档中的说明。我们监听一句话的开始OnSentenceBegin以及一句话的结束OnSentenceEnd事件,在这两个事件单中做一些业务相关的代码书写

  // tencentParams为初始化配置,里面的配置项我都没有修改,直接按照demo中的就行
  tencentSpeechTool = new WebAudioSpeechRecognizer(tencentParams);
  // 一句话开始
  tencentSpeechTool.OnSentenceBegin = (res) => {
    // 这里开启本地录音,上传录音,为人工校准提供参考的录音
  };
  // 一句话结束
  tencentSpeechTool.OnSentenceEnd = async (res) => {
    // 这里是代表一句话识别结束,初始化参数里vad_silence_time可以设置短剧检测阈值,不过文档上说不建议修改,有可能影响到识别的准确度
  };

对于录音文件上传我这里使用的是本地录音,也就是当一句话开始的时候开启录音,一句话结束的时候上传这句话的音频,并且在业务的socket信息中发送音频url,这样我们这条消息里就有语音转化的文字信息以及原始的音频可以供后续的使用了,下面是录音上传相关的代码

if (navigator.mediaDevices.getUserMedia) {
  let chunks: Array<Blob> = [];
  const constraints = { audio: true };
  navigator.mediaDevices.getUserMedia(constraints).then(
    (stream) => {
      console.log('授权成功!');
      mediaRecorder = new MediaRecorder(stream);

      mediaRecorder.ondataavailable = (e) => {
        chunks.push(e.data);
      };

      mediaRecorder.onstop = async () => {
        if (messageObj.value.text === '') return;
        const blob = new Blob(chunks, { type: 'audio/ogg; codecs=opus' });
        const file = new File([blob], `a${buildShortUUID()}`, {
          type: 'audio/ogg; codecs=opus',
        });
        const result = await tobUpload(file);
        messageObj.value.associatedUrl = result.url;
        send(JSON.stringify(messageObj.value));
        chunks = [];
      };
    },
    () => {
      console.error('授权失败!');
    },
  );
} else {
  console.error('浏览器不支持 getUserMedia');
}

其实我们使用WebRecorder类来获取浏览器采集的浏览器数据应该也是可以的,大家有兴趣的可以试一试。

小结

至此,在会议端功能实现的大致思路我们应该是比较清晰了,使用声网让大家可以在一个语音房里做交流,使用腾讯云语音sdk做语音转换成文字,并在合适的事件里做业务相关的操作(音频上传socket消息传递)等,因为没有做实际的UI设计,原型图上也就只有几个按钮,所以内容实现的样子可能是有点low,大家凑合看,反正是这个意思。

image.png

白板端实现

在会议端我们实现了语音会议及实时语音识别,这样对于每一场会议的数据就是消息的list,我们将会在白板端获取到会议的list,大概的数据结构是这样的:

[
    {
        meetingId: 'xxx',
        dialog: [
            {
                userInfo: { ... },
                content: {
                    text: '一句话',
                    url: '一句话音频的url'
                }
            },
            ...
        ]
    }
]

页面大概长这样

image.png 这里我们根据录音对不准确的文本进行人工修正,修正之后再对会议内容做关键信息提取,页面大概长这样

image.png 左边选中某一场会议的时候右侧出现整合会议记录的白板,我们先对会议打标签,具体流程就是选中某段文字点击卡片右上角的按钮这段话变成对应的标签。

image.png 点击右侧任务中某个标签后面的加号,点击左侧打好的标签,此标签整合到白板中且左侧对应标签变成不可点击状态。

image.png

难点

单纯的获取鼠标选中的文字我们可以使用const text = window.getSelection ? window.getSelection() : document.selection.createRange().text,不过我们需要需要记录每段文字在整段语音中开始和结束的位置,并且每段文字在整段文字中出现的次数并不唯一,我思考了很长时间但是最终的实现都有一些漏洞,最终公司的一位前端大佬给我推荐了markjs插件,可以说是非常好用,大家有兴趣可以看一下源码,可读性也很强

实现

使用这个markjs工具之后一切就都好弄了,下面是打标签的代码:

const hasSign = ref(false);
const targetSign = (index) => {
  hasSign.value = true;
  let m = new Mark(
    document.getElementById(
      `textContext${nowIndex.value}${originMeetingData.value.whiteboard_id}`,
    ),
  );
  const data = m.mark(`sign-${signCompList[index].id}`, {
    targetVoiceIndex: nowIndex.value,
    canset: 1,
    messageId: originMeetingData.value.dialog[nowIndex.value].message_id,
  });
  delete data.points;
  const markDom: HTMLDivElement | null = document.querySelector(`mark[markkey=${data.key}]`);
  markDom?.setAttribute('style', 'user-select: none');
  markDom?.setAttribute('data-options', JSON.stringify(data));
};

这里我直接把标签相关的信息存在了data属性里,这样在我们进行整合会议记录的时候我们可以使用事件委托更方便的取到对应标签的信息存到taskjson里。另外我们整合了某个标签之后直接把该标签变成不可选中的状态,以免被之后打标签的时候选中。下面是我们打好的标签的dom结构,可以看到我们想要的信息都在data-options属性里,我们在整合的时候直接取就行了非常的银杏。

image.png 打标签之后整合会议记录的代码如下:

// 子组件中我们使用事件委托获取到想要的标签信息:
const addTag = (e) => {
  if (!hasSign.value) return;
  if (props.addTagType === -1 && e.target.nodeName === NODETYPE.mark) {
    createMessage.info('请选择添加标签类型');
    return;
  }
  if (!e.target.dataset.options) return;
  const options = JSON.parse(e.target.dataset.options);
  const { type } = options;
  if (props.addTagType !== Number(TAGTYPE[type])) {
    createMessage.info('请选择对应类型的标签');
    return;
  }
  emit('pushTag', {
    tagData: options,
    messageIndex: nowIndex.value,
  });
};

// 父组件中我们拿到对应标签的信息往taskjson里面push
const pushTag = (payload) => {
  const { tagData, messageIndex } = payload;
  const { content, endOffset, length, messageId, startOffset, targetVoiceIndex } = tagData;
  const dialogueLen = uploadTaskPackage.value.dialogue.length;
  if (!tagData.canset) return;
  taskList.value[taskIndex.value]['messageIndex'] = messageIndex;
  taskList.value[taskIndex.value][tagTypeName.value].push({
    content,
    endOffset,
    length,
    messageId,
    startOffset,
    voiceIndex: targetVoiceIndex,
  });

  // task data 装载
  if (messageIndex + 1 === dialogueLen) {
    console.log('是最后一个');
    uploadTaskPackage.value.dialogue[messageIndex].tasks = JSON.parse(
      JSON.stringify(taskList.value),
    );
  }
  const markDom: HTMLDivElement | null = document.querySelector(
    `mark[markkey=${tagData.key}]`,
  );
  if (!markDom) return;
  markDom.setAttribute(
    'data-options',
    JSON.stringify({
      ...tagData,
      canset: 0,
    }),
  );
  markDom.style.backgroundColor = '#979494';
  markDom.style.cursor = 'not-allowed';
};

由于他们还想体现出taskjson的记录过程,也就是每一轮对话中taskjson的内容。比如我直接处理第二段对话,那么在第一段对话的时候taskjson里面的tasklist的数据就是空,于是我们需要手动补全一下每一段话的tasklist,这里我在提交的时候做

const submitTask = async () => {
  // 如果提交的taskData的dialogue中数组长度比所选中的会议的对话长度小,则证明最后一个处理的对话并不是整个会议
  // 对话中的最后一轮,需要补齐后面所有轮次对话,其tasks为最后一轮对话的taskList也就是当前的taskList
  if (uploadTaskPackage.value.dialogue.length < selectMeeting.value.dialog.length) {
    for (
      let i = uploadTaskPackage.value.dialogue.length;
      i < selectMeeting.value.dialog.length;
      i++
    ) {
      uploadTaskPackage.value.dialogue.push({
        ...formatMessage(selectMeeting.value.dialog[i], i),
        tasks: taskList.value,
      });
    }
  }
  const res = await uploadTasks(uploadTaskPackage.value);
  if (res.errcode === 0) {
   // dosomething
  }
};

至此整个需求就差不多结束了

结束语

整个需求虽然不难但是还算是挺有意思,因为时间匆忙可能做的也不太好,好在公司都是内部用来收集一些物料。大家感兴趣的可以去了解一下相关的内容,希望对大家工作需求中有所帮助。