基于canvas和three.js实现音频可视化一

390 阅读5分钟

前言 在数字化时代,我们正在见证音乐和视觉艺术的深度融合。流行音乐图像化,即将音乐视觉化,成为了一种独特的艺术表达方式。它跨越了时间和空间,用视觉元素诠释音乐的情感和氛围,为听众带来全新的感官体验。 今天分享的是一个基于WebAudio、canvas、three.js实现的简易音乐播放器。由于篇幅过长,所以分为三篇文章进行讲解,该篇文章主要叙述音乐播放的实现过程。
音乐播放功能实现请看上一篇文章 基于canvas和three.js实现音频可视化二
three.js实现可视化请看基于canvas和three.js实现音频可视化三

先来看下一最终效果:

20240919-212247-ezgif.com-video-to-gif-converter.gif

实现简介

该音乐播放器基于vue、vant搭建,主要功能通过Web Audio、canvas、three.js实现。 接下来先看一下音乐播放的实现: 功能实现分为以下几个步骤:

  1. 首先要完成的是页面布局及样式
  2. 实现歌曲的播放和暂停
  3. 实现歌曲总时长、歌曲播放倒计时时间展示功能
  4. 拖动进度条实现歌曲的快进、后退

1、页面结构及样式

贴出的代码是最终的页面结构,包含了绑定的方法和变量,具体怎么实现的,看一下代码就明白了,这里就不多赘述。

<template>
  <div>
    <img :src="picUrl" alt="" class="bgimg" />
    <!-- 轮播图区域显示 -->
    <div class="detaiBlock">
      <van-swipe class="my-swipe" :loop="false" indicator-color="white">
        <van-swipe-item>
          <div class="detailContent">
            <img src="@/assets/cd.png" alt="" class="img_cd" />
            <img :src="picUrl" alt="" class="img_ar" :class="{ img_ar_active: !isbtnShow, img_ar_pauesd: isbtnShow }" />
          </div>
        </van-swipe-item>
      </van-swipe>
    </div>
    <!-- 底部音乐播放控制 -->
    <div class="detailFooter">
      <div class="footerContent">
        <van-row justify="space-around" align="center">
          <van-col span="3" class="musicDown musicTime">{{ countDownTime }}</van-col>
          <van-col span="16">
            <input
              type="range"
              class="range"
              min="0"
              :max="duration"
              :value="currentTime"
              @input="changeRangeFn"
              step="0.05"
            />
          </van-col>
          <van-col span="3" class="musicTime"
            ><span>{{ musicTime(duration) }}</span></van-col
          >
        </van-row>
      </div>
      <div class="footer">
        <!-- <svg class="icon" aria-hidden="true">
          <use xlink:href="#icon-xunhuan"></use>
        </svg> -->
        <svg class="icon" aria-hidden="true">
          <use xlink:href="#icon-shangyishoushangyige"></use>
        </svg>
        <svg class="icon bofang" aria-hidden="true" v-if="isbtnShow" @click="e => play()">
          <use xlink:href="#icon-bofang1"></use>
        </svg>
        <svg class="icon bofang" aria-hidden="true" v-else @click="e => play()">
          <use xlink:href="#icon-zanting"></use>
        </svg>
        <svg class="icon" aria-hidden="true">
          <use xlink:href="#icon-xiayigexiayishou"></use>
        </svg>
        <!-- <svg class="icon" aria-hidden="true">
          <use xlink:href="#icon-zu"></use>
        </svg> -->
      </div>
    </div>
    <audio
      ref="audio"
      @loadedmetadata="e => onloadedmetadata(e)"
      :src="require('@/utils/music/yilushenghua.mp3')"
    ></audio>
  </div>
</template>
<style lang="less" scoped>
.bgimg {
  width: 100%;
  height: 100%;
  position: absolute;
  z-index: -1;
  filter: blur(70px);
  transition: all 1s;
}
.detailTop {
  width: 100%;
  height: 1rem;
  display: flex;
  padding: 0.2rem;
  justify-content: space-between;
  align-items: center;
  fill: #fff;
  .detailTopLeft {
    display: flex;
    align-items: center;
    .leftMarquee {
      width: 3rem;
      height: 100%;
      margin-left: 0.4rem;
      span {
        color: #82b8d1;
      }
      .icon {
        width: 0.3rem;
        height: 0.3rem;
        vertical-align: middle;
        fill: #82b8d1;
        margin-left: 0.1rem;
      }
    }
  }
}
.detaiBlock {
  // position: relative;
}
.detailContent {
  width: 100%;
  height: 9rem;
  display: flex;
  flex-direction: column;
  align-items: center;
  position: relative;
  .img_needle {
    width: 2rem;
    height: 3rem;
    position: absolute;
    left: 46%;
    transform-origin: 0 0;
    transform: rotate(-13deg);
    transition: all 2s;
  }
  .img_needle_active {
    width: 2rem;
    height: 3rem;
    position: absolute;
    left: 46%;
    transform-origin: 0 0;
    transform: rotate(0deg);
    transition: all 2s;
  }
  .my_canvas {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    z-index: -1;
  }
  .img_cd {
    width: 4rem;
    height: 4rem;
    position: absolute;
    // bottom: 2.3rem;
    top: 50%;
    transform: translateY(-50%);
    z-index: -1;
  }
  .img_ar {
    width: 2.5rem;
    height: 2.5rem;
    border-radius: 50%;
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    // bottom: 3.14rem;
    animation: rotate_ar 10s linear infinite;
  }
  .img_ar_active {
    animation-play-state: running;
  }
  .img_ar_pauesd {
    animation-play-state: paused;
  }
  @keyframes rotate_ar {
    0% {
      transform: translateY(-50%) rotateZ(0deg);
    }
    100% {
      transform: translateY(-50%) rotateZ(360deg);
    }
  }
}

.musicDown {
  text-align: right;
}
.musicTime {
  font-size: 0.2rem;
  color: #fff;
  margin-top: 0.13rem;
}
.threeContainer {
  width: 100%;
  height: 400px;
  // min-height: 400px;
}
.detailFooter {
  width: 100%;
  height: 3rem;
  position: absolute;
  bottom: 0.2rem;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  .footerTop {
    width: 100%;
    height: 1rem;
    display: flex;
    justify-content: space-around;
    align-items: center;
    .icon {
      width: 0.36rem;
      height: 0.36rem;
      fill: rgb(245, 234, 234);
    }
    .icon {
      width: 0.6rem;
      height: 0.6rem;
    }
  }
  .range {
    width: 100%;
    height: 0.06rem;
  }
  .footer {
    width: 100%;
    height: 1rem;
    display: flex;
    justify-content: space-around;
    align-items: center;
    .icon {
      fill: rgb(245, 234, 234);
    }
    .bofang {
      width: 1rem;
      height: 1rem;
    }
  }
}
</style>

2、实现歌曲的播放和暂停

首先是音乐播放,当点击播放按钮之后,我们先通过ref获取到音频对象,播放过程声音需要渐渐变大,我们给volume赋值为0,设置定时器渐变为1,大于等于1的时候清除定时器。 音乐暂停刚好相反, 我们给volume赋值为1,设置定时器渐变为0,小于等于0的时候清除定时器。

这里有个问题,暂停动作触发之后歌曲声音实际上并没有立即暂停,this.$refs.audio.paused获取到的值是false,所以点击暂停需要设置一个标识isToPause,代表当前状态在暂停中。 注意:在切换播放、暂停时要先清除定时器。

export default {
  data() {
    return {
      playTimer: null,
      pauseTimer: null,
      countTimer: null, // 倒计时定时器
      isToPause: false, // 暂停动作标识
      currentTime: 0, //当前时间
      duration: 0, //歌曲总时长
      countDownTime: 0, // 倒计时时间
      picUrl: 'https://p1.music.126.net/6y-UleORITEDbvrOLV0Q8A==/5639395138885805.jpg'
    }
  },
  methods: {
    // 播放或暂停
    async play() {
      // 在切换播放、暂停时先清除定时器
      clearInterval(this.playTimer)
      clearInterval(this.pauseTimer)
      // 判断音乐是否播放
      // 暂停动作触发之后歌曲声音实际上并没有立即暂停,而是渐渐减小,
      // 这里设置一个标识isToPause用来表示当前是暂停状态
      if (this.$refs.audio.paused || this.isToPause) {
        this.isToPause = false
        this.handlerPlay()
      } else {
        this.handlerPause()
      }
    },
    // 播放(声音缓慢变大)
    handlerPlay(e) {
      const musicRef = this.$refs.audio
      if (!musicRef) {
        return
      }
      musicRef.play()
      musicRef.volume = 0
      let v = 0
      this.playTimer = setInterval(() => {
        v = v + 0.1
        if (v <= 1) {
          musicRef.volume = v
        } else {
          clearInterval(this.playTimer)
        }
      }, 200)
    },
    // 暂停(声音渐渐减小)
    handlerPause() {
      this.isToPause = true
      const musicRef = this.$refs.audio
      if (!musicRef) {
        return
      }
      let v = 1
      this.pauseTimer = setInterval(() => {
        v -= 0.1
        if (v > 0) {
          musicRef.volume = v
        } else {
          clearInterval(this.pauseTimer)
          this.isToPause = false
          musicRef.pause()
        }
      }, 200)
    },
   }
 }

3、实现歌曲播放倒计时功能

添加onloadedmetadata事件,通过歌曲dom对象duration属性获取歌曲时长,获取到的时间为秒(s)。 通过歌曲dom对象currentTime属性获取歌曲当前播放时长秒](s),总时长减去当前播放的时间点就是歌曲剩余播放时长,然后设置定时器即可。具体代码如下:

公共方法

function formatTime(time) {
  const min = parseInt(time / 60)
  const sec = parseInt(time % 60)
  return `${min < 10 ? '0' + min : min} : ${sec < 10 ? '0' + sec : sec}`
}

computed

computed: {
  // 歌曲总时长格式化
  musicTime() {
    return time => formatTime(time)
  }
}

以下添加到methods

// 获取音乐时间
onloadedmetadata(e) {
   this.duration = e.target.duration
 },
// 设置倒计时
setCountDown() {
  const duration = this.$refs.audio.duration
  const curTime = this.$refs.audio.currentTime || 0
  let target = duration - curTime
  if (isNaN(target)) {
    clearInterval(this.countTimer)
    return
  }
  if (this.countTimer) {
    clearInterval(this.countTimer)
  }
  this.countTimer = setInterval(() => {
    const time = formatTime(--target)
    this.countDownTime = time
    this.currentTime = duration - target
    if (target <= 0) {
      clearInterval(this.countTimer)
    }
  }, 1000)
},

修改play方法


// 播放或暂停
async play() {
  // 在切换播放、暂停时先清除定时器
  clearInterval(this.playTimer)
  clearInterval(this.pauseTimer)
  clearInterval(this.countTimer)
  // 判断音乐是否播放
  // 暂停动作触发之后歌曲声音实际上并没有立即暂停,而是渐渐减小,
  // 这里设置一个标识isToPause用来表示当前是暂停状态
  // 判断音乐是否播放
  if (this.$refs.audio.paused || this.isToPause) {
    this.isToPause = false
    this.handlerPlay()
    setTimeout(() => {
      this.setCountDown()
    }, 1000)
  } else {
    this.handlerPause()
  }
},

4、拖动进度条实现歌曲的快进、后退

首先添加input标签type类型为range,绑定最大值duration(歌曲总时长)、最小值为0,绑定value值等于currentTime(当前播放时间),代码如下:

<input
  type="range"
  class="range"
  min="0"
  :max="duration"
  :value="currentTime"
  @input="changeRangeFn"
  step="0.05"
/>

这里拖动进度条要设置防抖,随后获取当前值,赋值给歌曲对象属性currentTime,然后需要重新设置歌曲倒计时,代码如下:

公共方法

function debounce(fn, time) {
  var timer
  return function () {
    var _this = this
    var args = arguments
    if (timer) {
      clearTimeout(timer)
    }
    timer = setTimeout(function () {
      fn.apply(_this, args)
    }, time)
  }
}

添加生命周期方法

 mounted() {
  // this.initMusicOrigin();
   this.changeRangeFn = debounce(this.changeRange, 200)
 },
 beforeDestroy () {
   // 方法使用了闭包,这里释放一下内存
   this.changeRangeFn = null
 }

methods添加方法

 // 更改音乐播放时间
changeRange(e) {
   // 在音频播放器中指定播放时间
   this.$refs.audio.currentTime = e.target.value
   this.setCountDown()
 },

最终代码

<script>
function debounce(fn, time) {
  var timer
  return function () {
    var _this = this
    var args = arguments
    if (timer) {
      clearTimeout(timer)
    }
    timer = setTimeout(function () {
      fn.apply(_this, args)
    }, time)
  }
}
function formatTime(time) {
  const min = parseInt(time / 60)
  const sec = parseInt(time % 60)
  return `${min < 10 ? '0' + min : min} : ${sec < 10 ? '0' + sec : sec}`
}
export default {
  data() {
    return {
      playTimer: null,
      pauseTimer: null,
      countTimer: null, // 倒计时定时器
      isToPause: false, // 暂停动作标识
      currentTime: 0, //当前时间
      duration: 0, //歌曲总时长
      countDownTime: 0, // 倒计时时间
      picUrl: 'https://p1.music.126.net/6y-UleORITEDbvrOLV0Q8A==/5639395138885805.jpg'
    }
  },
  computed: {
    // 设置时间格式
    musicTime() {
      return time => formatTime(time)
    }
  },
  mounted() {
    // this.initMusicOrigin();
    this.changeRangeFn = debounce(this.changeRange, 200)
  },
  beforeDestroy () {
    // 方法使用了闭包,这里释放一下内存
    this.changeRangeFn = null
  },
  methods: {
    // 播放或暂停
    async play() {
      // 在切换播放、暂停时先清除定时器
      clearInterval(this.playTimer)
      clearInterval(this.pauseTimer)
      clearInterval(this.countTimer)
      // 判断音乐是否播放
      // 暂停动作触发之后歌曲声音实际上并没有立即暂停,而是渐渐减小,
      // 这里设置一个标识isToPause用来表示当前是暂停状态
     if (this.$refs.audio.paused || this.isToPause) {
       this.isToPause = false
       this.handlerPlay()
       setTimeout(() => {
         this.setCountDown()
       }, 1000)
     } else {
       this.handlerPause()
     }
    },
    // 播放(声音缓慢变大)
    handlerPlay(e) {
      const musicRef = this.$refs.audio
      if (!musicRef) {
        return
      }
      musicRef.play()
      musicRef.volume = 0
      let v = 0
      this.playTimer = setInterval(() => {
        v = v + 0.1
        if (v <= 1) {
          musicRef.volume = v
        } else {
          clearInterval(this.playTimer)
        }
      }, 200)
    },
    // 暂停(声音渐渐减小)
    handlerPause() {
      this.isToPause = true
      const musicRef = this.$refs.audio
      if (!musicRef) {
        return
      }
      let v = 1
      this.pauseTimer = setInterval(() => {
        v -= 0.1
        if (v > 0) {
          musicRef.volume = v
        } else {
          clearInterval(this.pauseTimer)
          this.isToPause = false
          musicRef.pause()
        }
      }, 200)
    },
    // 获取音乐时间
	onloadedmetadata(e) {
	   this.duration = e.target.duration
	 },
	// 设置倒计时
	setCountDown() {
	  const duration = this.$refs.audio.duration
	  const curTime = this.$refs.audio.currentTime || 0
	  let target = duration - curTime
	  if (isNaN(target)) {
	    clearInterval(this.countTimer)
	    return
	  }
	  if (this.countTimer) {
	    clearInterval(this.countTimer)
	  }
	  this.countTimer = setInterval(() => {
	    const time = formatTime(--target)
	    this.countDownTime = time
	    this.currentTime = duration - target
	    if (target <= 0) {
	      clearInterval(this.countTimer)
	    }
	  }, 1000)
	},
	// 更改音乐播放时间
    changeRange(e) {
      // 在音频播放器中指定播放时间
      this.$refs.audio.currentTime = e.target.value
      this.setCountDown()
    },
   }
 }
 </script>