Skip to content
待添加

仿网易云-360度混响

一直在用网易云音乐听歌,感觉他的这个动效还是挺不错的,最近也是想试试canvas绘图相关的。尝试了几次之后感觉效果还不错,不过距离网易云的还是有些差距。

本期准备仿照制作如下效果:

偷偷使用最近比较流行的罗刹海市的音乐来展示这个效果。

效果展示如下:

效果展示网站

具体的流程大体上就是获取音频数据,然后根据音频数据绘制在canvas上,不同的绘制方式就能有很多惊艳的效果,毕竟还是数学最令人着迷了。

提取音频数据

普通的audio标签无法提取音频数据,查了一下需要使用到 AudioContext 这个浏览器内置对象。 使用来说也比较简单,大体上就是创建,加载音频,播放等几个环境,在播放的时候通过链接音频处理节点获取音频数据。

几个比较重要的点如下:

javascript
window.AudioContext = window.AudioContext || window.webkitAudioContext;
this.audioCtx = new AudioContext()

// 创建播放节点
this.bufferSourceNode = this.audioCtx.createBufferSource()
this.bufferSourceNode.connect(this.audioCtx.destination)

// 创建分析器,从这个分析器中能够得到频域跟时域的数据,不过频域的数据分散不够均匀,感觉还是时域的展示效果好一些
this.analyser = this.audioCtx.createAnalyser()
this.analyser.fftSize = this.sampleRate
this.analyser.smoothingTimeConstant = 1
this.player.bufferSourceNode.connect(this.analyser)

this.analyser.getByteTimeDomainData(data)

剩下的就是把这些内容给组织起来了。

javascript
class Player {
  constructor(canvasName) {
    window.AudioContext = window.AudioContext || window.webkitAudioContext;

    this.audioCtx = new AudioContext()
    this.bufferSourceNode = this.audioCtx.createBufferSource()
    this.canvas = document.getElementById(canvasName)
    this.audioBuffer = null
    
    this.initEffect()
  }

  stop() {
    this.audioCtx.suspend()
  }

  resume() {
    this.audioCtx.resume()
  }

  initEffect() {
    this.bufferSourceNode = this.audioCtx.createBufferSource()
    this.bufferSourceNode.connect(this.audioCtx.destination)
    // 显示效果,可以随时替换
    this.effect = new DefaultEffect(this)
  }
  
  play(url) {
    let that = this

    if (that.audioBuffer) {
      that.initEffect()
    }

    fetch(url, {
      method: 'get',
      responseType: 'arraybuffer'
    }).then(res => {     
      return res.arrayBuffer();
    }).then(arraybuffer => {
      that.audioCtx.decodeAudioData(arraybuffer, function(buffer) {
        that.audioBuffer = buffer
        that.bufferSourceNode.buffer = buffer
        that.bufferSourceNode.start(0)
      });
    })
  }
}

360混响效果

看画面他像从圆心发射的线条,只不过用一个内圆把中间给盖上了。 那么接下来就是先画一个放射线,只需要按照均衡的角度来画一下就完事了。 本例子这个效果把园分了128条线,并且把其实坐标给从圆心偏移到内圆的边上。 canvas画线的API如下:

javacript
let jiaodu = i * 360 / count
let sx = Math.sin(jiaodu * Math.PI / 180) * minRadius
let sy = Math.cos(jiaodu * Math.PI / 180) * minRadius

let d = Math.max(0, this.lastData[i] - 127)
// let d = data[i] - 90
let endRatio = (minRadius + (d / 128) * 100) / minRadius

let ex = Math.sin(jiaodu * Math.PI / 180) * minRadius * endRatio
let ey = Math.cos(jiaodu * Math.PI / 180) * minRadius * endRatio

sx += centerX
sy += centerY
ex += centerX
ey += centerY

this.ctx.beginPath();
this.ctx.moveTo(ex, ey); // 起点
this.ctx.lineWidth = 4;
this.ctx.lineCap = "round"; // 圆角,看起来更好看一点
this.ctx.lineTo(lex, ley); // 终点
this.ctx.strokeStyle = 'rgba(255,0,0,0.1)'; // 颜色
this.ctx.stroke();

效果如下:

为了让这个更好看一点,看到好像能够在这个线的外层再画一层,只不过颜色看起来比较浅。

另外接上时域数据之后,发现跳动比较大,然后加了一层缓存,减缓曲线的回落速度。

javascript
for(let i = 0; i < this.sampleRate; i++) {
  if (this.lastData[i]> 8) this.lastData[i] -= 8
  this.lastData[i] = Math.max(this.lastData[i], data[i])
}

整体效果代码如下:

javascript
class DefaultEffect {
  constructor(player) {
    this.player = player
    this.width = player.canvas.width
    this.height = player.canvas.height
    this.ctx = player.canvas.getContext('2d')
    this.sampleRate = 128
    this.audioCtx = player.audioCtx
    this.lastData = new Uint8Array(this.sampleRate)
    
    this.analyser = this.audioCtx.createAnalyser()
    this.analyser.fftSize = this.sampleRate
    this.analyser.smoothingTimeConstant = 1
    this.player.bufferSourceNode.connect(this.analyser)
  }

  // 当暂停的时候使用这个输出默认的效果,是动画不那么呆板
  idleData(delta) {
    let data = []
    for(let i = 0; i < this.sampleRate; i++) {
      data.push((Math.sin(i + delta/1000) + 1) * 20 + 127)
    }
    return data
  }

  getData(delta) {
    var data = new Uint8Array(this.sampleRate)
    
    if (this.audioCtx) {
      if (this.audioCtx.state == 'running') {
        this.analyser.getByteTimeDomainData(data)
      } else {
        data = this.idleData(delta)
      }
    } else {
      data = this.idleData(delta)
    }
    return data
  }

  draw(delta) {
    this.ctx.clearRect(0,0,this.width,this.height)

    let data = this.getData(delta)

    for(let i = 0; i < this.sampleRate; i++) {
      if (this.lastData[i]> 8) this.lastData[i] -= 8
      this.lastData[i] = Math.max(this.lastData[i], data[i])
    }

    let centerX = this.width/2
    let centerY = this.height/2

    let minRadius = 150

    let count = 128
    for(let i = 0; i < count; i++) {
      let jiaodu = i * 360 / count
      let sx = Math.sin(jiaodu * Math.PI / 180) * minRadius
      let sy = Math.cos(jiaodu * Math.PI / 180) * minRadius

      let d = Math.max(0, this.lastData[i] - 127)
      // let d = data[i] - 90
      let endRatio = (minRadius + (d / 128) * 100) / minRadius

      let ex = Math.sin(jiaodu * Math.PI / 180) * minRadius * endRatio
      let ey = Math.cos(jiaodu * Math.PI / 180) * minRadius * endRatio

      sx += centerX
      sy += centerY
      ex += centerX
      ey += centerY

      this.ctx.beginPath();
      this.ctx.moveTo(sx, sy);
      this.ctx.lineWidth = 4;
      this.ctx.lineCap = "round";
      this.ctx.lineTo(ex, ey);
      this.ctx.strokeStyle = 'red';
      this.ctx.stroke();

      let lex = Math.sin(jiaodu * Math.PI / 180) * (minRadius * endRatio + 30)
      let ley = Math.cos(jiaodu * Math.PI / 180) * (minRadius * endRatio + 30)
      lex += centerX
      ley += centerY

      this.ctx.beginPath();
      this.ctx.moveTo(ex, ey);
      this.ctx.lineWidth = 4;
      this.ctx.lineCap = "round";
      this.ctx.lineTo(lex, ley);
      this.ctx.strokeStyle = 'rgba(255,0,0,0.1)';
      this.ctx.stroke();
    }
  }
}

使用的时候

初始化一个player,然后定时刷新canvas以显示效果。

javascript
var player;
var effectName;
onMounted( () => {
  player = new Player('myCanvas', effectName)
  setInterval(() => {
    if (player) {
      requestAnimationFrame(player.effect.draw.bind(player.effect))
    }
  }, 50);
  player.play(url)
})