仿微信语音聊天webrtc

image

主要技术

MediaRecorder 录音
webrtc 获取麦克风
URL.createObjectURL 转换为url(实际生产中,通过后端转换blob为mp3网址)

实现elementui+vue

1.html

<div class="chat-record">
      <audio ref="chataudio"></audio>
      <transition-group tag="ul" class="msg-list" name="fade">
        <li v-for="(item, index) in chunList" :key="index" class="msg" @click="onPlay(index)" @touchend.prevent="onPlay(index)" :style="`flex-direction:${item.sendUserName===userName?'row-reverse':'row'}`">
          <div class="avatar">
            <dt>{{item.sendUserName}}</dt>
            <dd>{{item.sendTime}}</dd>
          </div>
          <div v-cloak class="audio"  :style="{width: 20 * item.audioStream + 'px'}" :class="{wink: item.wink,rotate:item.sendUserName!==userName}">
            <span>(</span>
            <span>(</span>
            <span>(</span>
          </div>
          <div class="duration">{{item.audioStream}}"</div>
        </li>
      </transition-group>
    </div>
    <span slot="footer" class="dialog-footer">
      <el-button type="primary" class="submit-btn" @mousedown.native="onMousedown" @touchstart.native="onMousedown" @mouseup.native="onMouseup" @touchend.native="onMouseup">{{btnText}}</el-button>
    </span>

touchstart.native为了移动端触感灵敏

2.判断浏览器是否有相关api

function getUserMedia(constrains, success, error) {
    let promise
    if (navigator.mediaDevices.getUserMedia) {
      //最新标准API
      promise = navigator.mediaDevices
        .getUserMedia(constrains)
        .then(success)
        .catch(error)
    } else if (navigator.webkitGetUserMedia) {
      //webkit内核浏览器
      promise = navigator
        .webkitGetUserMedia(constrains)
        .then(success)
        .catch(error)
    } else if (navigator.mozGetUserMedia) {
      //Firefox浏览器
      promise = navigator.mozGetUserMedia(constrains).then(success).catch(error)
    } else if (navigator.getUserMedia) {
      //旧版API
      promise = navigator.getUserMedia(constrains).then(success).catch(error)
    }
    return promise
  }
  function canGetUserMediaUse() {
    return !!(
      navigator.mediaDevices.getUserMedia ||
      navigator.webkitGetUserMedia ||
      navigator.mozGetUserMedia ||
      navigator.msGetUserMedia
    )
  }

3. new window.MediaRecorder(stream)

 if (canGetUserMediaUse()) {
          getUserMedia(
            {
              video: false,
              audio: true,
            },
            (stream) => {
              this.recorder = new window.MediaRecorder(stream)
              this.bindEvents()
            },
            (error) => {
              console.log(error)
              alert('出错,请确保已允许浏览器获取录音权限' + error)
            }
          )
        } else {
          alert('您的浏览器不兼容')
        }
      },

4.录音开始,暂停,保存,转换

 onMousedown() {
        this.btnText = '松开结束'
        this.onStart()
      },

      onMouseup() {
        this.btnText = '按住说话'
        this.onStop()
      },

      onStart() {
        this.recorder.start()
      },

      onStop() {
        this.recorder.stop()
      },
	 //重点
	 bindEvents() {
        this.recorder.ondataavailable = this.getRecordingData
        this.recorder.onstop = this.saveRecordingData
      },

      getRecordingData(e) {
        this.chunks.push(e.data)
      },
      saveRecordingData() {
        let blob = new Blob(this.chunks, { type: 'audio/ogg; codecs=opus' })
        let audioStream = URL.createObjectURL(blob)
        //估算时长
        let duration = parseInt(blob.size / 6600)
        if (duration <= 0) {
          alert('说话时间太短')
          return
        }
        if (duration > 60) {
          duration = 60
        }
        this.chunkList.push({audioStream: audioStream, duration: duration})
        this.chunks = []
      },

5.播放

onPlay(index) {
        let item = this.chunList[index]
        this.audio = this.$refs.chataudio
        this.audio.src = item.audioStream
        this.audio.play()
        this.bindAudioEvent(index)
      },

完整代码

这是props获取websoket数据,自己使用请把wsdata换成chunkList

点击查看代码
<template>
  <el-dialog append-to-body class="box-dialog" custom-class="dark-dialog" :visible.sync="dialogVisible" width="500px"  center>
    <div class="chat-record">
      <audio ref="chataudio"></audio>
      <transition-group tag="ul" class="msg-list" name="fade">
        <li v-for="(item, index) in wsdata" :key="index" class="msg" @click="onPlay(index)" @touchend.prevent="onPlay(index)" :style="`flex-direction:${item.sendUserName===userName?'row-reverse':'row'}`">
          <div class="avatar">
            <dt>{{item.sendUserName}}</dt>
            <dd>{{item.sendTime}}</dd>
          </div>
          <div v-cloak class="audio"  :style="{width: 20 * item.sendContentLength + 'px'}" :class="{wink: item.wink,rotate:item.sendUserName!==userName}">
            <span>(</span>
            <span>(</span>
            <span>(</span>
          </div>
          <div class="duration">{{item.sendContentLength}}"</div>
        </li>
      </transition-group>
    </div>
    <span slot="footer" class="dialog-footer">
      <el-button type="primary" class="submit-btn" @mousedown.native="onMousedown" @touchstart.native="onMousedown" @mouseup.native="onMouseup" @touchend.native="onMouseup">{{btnText}}</el-button>
    </span>
  </el-dialog>
</template>

<script>
  function getUserMedia(constrains, success, error) {
    let promise
    if (navigator.mediaDevices.getUserMedia) {
      //最新标准API
      promise = navigator.mediaDevices
        .getUserMedia(constrains)
        .then(success)
        .catch(error)
    } else if (navigator.webkitGetUserMedia) {
      //webkit内核浏览器
      promise = navigator
        .webkitGetUserMedia(constrains)
        .then(success)
        .catch(error)
    } else if (navigator.mozGetUserMedia) {
      //Firefox浏览器
      promise = navigator.mozGetUserMedia(constrains).then(success).catch(error)
    } else if (navigator.getUserMedia) {
      //旧版API
      promise = navigator.getUserMedia(constrains).then(success).catch(error)
    }
    return promise
  }
  function canGetUserMediaUse() {
    return !!(
      navigator.mediaDevices.getUserMedia ||
      navigator.webkitGetUserMedia ||
      navigator.mozGetUserMedia ||
      navigator.msGetUserMedia
    )
  }
  export default {
    data() {
      return {
        dialogVisible: false,
        chunks: [],
        audio: '',
        chunkList: [],
        btnText: '按住说话',
      }
    },
    props: {
      wsdata: {
        type: Array,
        default: () => {},
      },
      userName: {
        type: String,
        default: '',
      },
    },
    mounted() {
      this.requestAudioAccess()
    },
    methods: {
      open(data) {
        this.dialogVisible = true
      },
      close() {
        this.dialogVisible = false
        this.resetForm()
      },
      requestAudioAccess() {
        if (canGetUserMediaUse()) {
          getUserMedia(
            {
              video: false,
              audio: true,
            },
            (stream) => {
              this.recorder = new window.MediaRecorder(stream)
              this.bindEvents()
            },
            (error) => {
              console.log(error)
              alert('出错,请确保已允许浏览器获取录音权限' + error)
            }
          )
        } else {
          alert('您的浏览器不兼容')
        }
      },

      onMousedown() {
        this.btnText = '松开结束'
        this.onStart()
      },

      onMouseup() {
        this.btnText = '按住说话'
        this.onStop()
      },

      onStart() {
        this.recorder.start()
      },

      onStop() {
        this.recorder.stop()
      },

      onPlay(index) {
        this.wsdata.forEach((item) => {
          this.$set(item, 'wink', false)
        })
        let item = this.wsdata[index]
        this.audio = this.$refs.chataudio
        this.audio.src = item.sendContent
        this.audio.play()

        this.bindAudioEvent(index)
      },

      bindAudioEvent(index) {
        let item = this.wsdata[index]

        this.audio.onplaying = () => {
          this.$set(item, 'wink', true)
        }

        this.audio.onended = () => {
          this.$set(item, 'wink', false)
        }
      },

      bindEvents() {
        this.recorder.ondataavailable = this.getRecordingData
        this.recorder.onstop = this.saveRecordingData
      },

      getRecordingData(e) {
        this.chunks.push(e.data)
      },

      saveRecordingData() {
        let blob = new Blob(this.chunks, { type: 'audio/ogg; codecs=opus' })
        let audioStream = URL.createObjectURL(blob)
        //估算时长
        let duration = parseInt(blob.size / 6600)
        if (duration <= 0) {
          alert('说话时间太短')
          return
        }
        if (duration > 60) {
          duration = 60
        }
        this.$emit('getvoice', {blob: blob, duration: duration})
        this.chunks = []
      },
    },
  }
</script>
<style lang="scss" scoped>
.box-dialog::v-deep .el-dialog__footer {
  padding:0 0;
  padding-bottom: 10px;
}
.box-dialog::v-deep .el-dialog__body {
  padding-bottom:0;
  //background-color: rgba(#181b40, 0.9);
}
.box-dialog::v-deep .el-dialog__header {
  display: none;
}
.submit-btn {
  width: 96%;
  height: 45px;
  position: relative;
  background-color: #181b40;
  &:active {
  background-color: #03225c;
}
&:active:before {
  position: absolute;
  left: 50%;
  transform: translate(-50%, 0);
  top: -2px;
  content: '';
  width: 0%;
  height: 2px;
  background-color: #7bed9f;
  animation: loading 1s ease-in-out infinite backwards;
}
}

.chat-record {
  width: 100%;
  height: 300px;
  overflow-y: scroll;
  color: #fff;

}
.msg-list {
  margin: 0;
  padding: 0;
  height: 100%;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}
.msg-list::-webkit-scrollbar {
  display: none;
}
.msg-list .msg {
  list-style: none;
  padding: 0 8px;
  margin: 10px 0;
  overflow: hidden;
  cursor: pointer;
}
.msg-list .msg  {
  display: flex;
  flex-direction: row-reverse;
}
.rotate{
  transform: rotate(180deg);
}
.msg-list .msg .avatar {
  height: 34px;
  line-height: 14px;
  font-size: 12px;
  background-size: 100%;
  dt {
    font-size: 14px;
  }
}
.msg-list .msg .audio {
  position: relative;
  margin-right: 6px;
  max-width: 116px;
  min-width: 30px;
  height: 24px;
  line-height: 24px;
  padding: 0 4px 0 10px;
  border-radius: 2px;
  color: #000;
  text-align: right;
  background-color: rgba(107, 197, 107, 0.85);
}
.msg-list .msg.eg {
  cursor: default;
}
.msg-list .msg.eg .audio {
  text-align: left;
}
.msg-list .msg .audio:before {
  position: absolute;
  right: -8px;
  top: 8px;
  content: '';
  display: inline-block;
  width: 0;
  height: 0;
  border-style: solid;
  border-width: 4px;
  border-color: transparent transparent transparent rgba(107, 197, 107, 0.85);
}
.msg-list .msg .audio span {
  color: rgba(255, 255, 255, 0.8);
  display: inline-block;
  transform-origin: center;
}
.msg-list .msg .audio span:nth-child(1) {
  font-weight: 400;
}
.msg-list .msg .audio span:nth-child(2) {
  transform: scale(0.8);
  font-weight: 500;
}
.msg-list .msg .audio span:nth-child(3) {
  transform: scale(0.5);
  font-weight: 700;
}
.msg-list .msg .audio.wink span {
  animation: wink 1s ease infinite;
}
.msg-list .msg .duration {
  margin: 3px 2px;
}
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.5s;
}
.fade-enter,
.fade-leave-to {
  opacity: 0;
}
@keyframes wink {
  from {
    color: rgba(255, 255, 255, 0.8);
  }
  to {
    color: rgba(255, 255, 255, 0.1);
  }
}
@keyframes loading {
  from {
    width: 0%;
  }
  to {
    width: 100%;
  }
}
</style>

posted @ 2022-07-26 11:24  流云君  阅读(301)  评论(0编辑  收藏  举报