Lavender's Blog

浩瀚众星,皆降为尘

从一个问题,走向发现

为 Shoka 右下角音乐播放器增加音效可视化开关,使用 Web Audio API 分析真实音乐能量,并在页面顶部原有波浪区域绘制彩色、平滑且随节奏起伏的音频波形。

#前言

Shoka 自带的音乐播放器可以播放歌单,但默认页面波浪只是固定动画,与当前音乐内容没有关系。本次改造会在播放器内部加入一个音效可视化按钮:开启后,原有 SVG 波浪自然淡出,Canvas 彩色波形接替显示;暂停或关闭功能后,页面再平滑恢复原来的波浪。

最终效果包含:

  • 可在音乐面板中实时开启或关闭;
  • 开关状态保存到浏览器,刷新页面后仍然保留;
  • 优先读取真实频率、低频能量和瞬时冲击;
  • 波峰和波谷均会跟随音乐起伏,而不是固定循环;
  • 使用多层贝塞尔曲线绘制圆滑波形;
  • 开启可视化并播放音乐时,原有 SVG 波浪自然淡出;
  • 暂停、切歌、PJAX 切换和窗口缩放后仍能继续工作;
  • 无法取得真实音频数据时提供柔和的后备动画;
  • 支持键盘操作、日间/夜间模式和减少动态效果设置。

#开始前准备

第一次修改主题时,建议先完成下面几项:

  1. 找到博客根目录。能看到 _config.ymlpackage.jsonsourcethemes 的目录就是根目录;
  2. 备份 themes/shoka/source/js/_app/player.js 以及本教程涉及的两个 Stylus 文件,或者先提交一次 Git;
  3. 确认主题配置中的 audio 已经可以正常加载并播放音乐;
  4. 在博客根目录执行一次 npm install ,确保原项目依赖完整;
  5. 本功能不需要安装新的 npm 包,浏览器原生的 Web Audio API 和 Canvas 已经足够;
  6. 建议通过 hexo server 的本地地址或 HTTPS 网站测试,不要直接双击生成的 HTML 文件。

音频可视化最容易遇到的问题是跨域。即使 <audio> 可以播放远程音乐,音乐服务器没有返回正确的 CORS 响应头时,浏览器仍可能禁止 Web Audio API 读取频率数据。前端代码无法绕过服务器的跨域策略。

#文件结构

本次主要涉及以下文件:

themes/shoka/source/js/_app/player.js
themes/shoka/source/css/_common/outline/header/waves.styl
themes/shoka/source/css/_common/components/tags/player.styl

主题原有的 themes/shoka/source/js/_app/pjax.js 已经负责创建播放器并在页面切换后重新加载歌单,本项目不需要修改它。

#文件变更清单

操作文件具体变化
修改themes/shoka/source/js/_app/player.js增加可视化控制按钮、状态保存、Canvas 创建、Web Audio 分析器、频率采样、能量平滑、多层波形绘制和播放生命周期同步。
修改themes/shoka/source/css/_common/outline/header/waves.styl增加 Canvas 的定位、遮罩、过渡和暗色样式;播放可视化时让原 SVG 波浪淡出。
修改themes/shoka/source/css/_common/components/tags/player.styl增加可视化按钮图标、激活颜色、状态圆点、键盘焦点和控制栏等宽布局。
检查,通常无需修改themes/shoka/_config.yml确认已有可播放的 audio 歌单。本功能本身没有新增配置项。

本次没有删除任何文件,也没有引入第三方频谱库。不要修改 public/js/app.jspublic/css/app.css ,它们会在下一次执行 hexo clean 时被重新生成。

#实现原理

整个过程可以理解为下面五步:

  1. 用户点击播放器中的可视化按钮;
  2. 程序创建覆盖在 #waves 区域上的 Canvas;
  3. AudioContextAnalyserNode 从当前 <audio> 元素读取频率及波形数据;
  4. 每一帧计算整体能量、低频能量、冲击能量和多个频段的高度;
  5. Canvas 绘制三层彩色曲线,同时 CSS 隐藏原来的 SVG 波浪。

暂停时不再使用强烈的音频起伏,只保留很轻的待机波动。关闭可视化后延迟停止绘制,让淡出动画有时间完成。

#实现

#第 1 步:加入可视化按钮

打开 themes/shoka/source/js/_app/player.js ,在播放器默认控制列表中加入 visualizer 。当前位置放在 “下一首” 和 “音量” 之间:

var option = {
  type: 'audio',
  mode: 'random',
  btns: ['play-pause', 'music'],
  controls: [
    'mode',
    'lyrics',
    'backward',
    'play-pause',
    'forward',
    'visualizer',
    'volume'
  ],
  events: {
    visualizer: function () {
      visualizer.toggle()
    }
  }
}

controls 决定音乐面板内部按钮的顺序, events.visualizer 则把按钮点击交给后面创建的可视化管理器。

文章正文中通过标签创建的独立播放器不需要占用页面顶部波浪,因此初始化时要保留下面的过滤逻辑:

if (!isToolPlayer && t.player.options.controls) {
  t.player.options.controls = t.player.options.controls.filter(function (item) {
    return item !== 'visualizer' && item !== 'lyrics'
  })
}

这里的 isToolPlayer 表示右下角全局播放器。只有它才显示可视化按钮。

#第 2 步:让按钮支持状态和键盘操作

创建控制按钮后,为 visualizer 增加按钮语义、键盘触发方式和状态同步:

if (item === 'visualizer' || item === 'lyrics') {
  that.btns[item].tabIndex = 0
  that.btns[item].attr('role', 'button')
  that.btns[item].addEventListener('keydown', function (event) {
    if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault()
      event.currentTarget.click()
    }
  })
}

visualizer.syncButton() 中同步下面三个信息:

  • active 类:负责激活颜色和绿色状态点;
  • aria-pressed :让辅助设备知道开关状态;
  • title :根据状态显示 “开启” 或 “关闭音效可视化”。

开关状态保存在浏览器存储中:

enabled: isToolPlayer && store.get('_PlayerVisualizer') === 'on'

用户开启时写入 _PlayerVisualizer=on ,关闭时删除该值,因此默认状态为关闭。

#第 3 步:创建 Canvas 并接管波浪区域

player.js 中新增 visualizer 对象,并先实现 Canvas 创建:

ensureCanvas: function () {
  if (!isToolPlayer) return false
  var waves = $('#waves')
  if (!waves) return false
  if (!this.el) {
    this.el = waves.child('.music-visualizer')
    if (!this.el) {
      this.el = waves.createChild('canvas', {
        className: 'music-visualizer'
      })
      this.el.attr('aria-hidden', 'true')
    }
    this.ctx = this.el.getContext('2d')
  }
  return !!this.ctx
}

Canvas 放在已有的 #waves 内,所以不需要修改 Nunjucks 模板。第一次开启时才创建它,避免没有使用该功能时产生额外绘制开销。

syncState() 负责给 #waves 添加状态类:

var active = this.enabled && this.available
if (active && this.ensureCanvas()) {
  waves.addClass('visualizer-enabled')
  waves.toggleClass('visualizer-playing', this.playing)
  this.start()
  if (this.playing) this.setupAnalyser()
} else {
  waves.removeClass('visualizer-enabled visualizer-playing')
  this.stopSoon()
}

状态类的含义如下:

类名含义
visualizer-enabled用户已经打开音效可视化。
visualizer-playing可视化已打开,并且当前音乐正在播放。

#第 4 步:连接 Web Audio 分析器

创建分析器时需要设置采样精度和平滑系数:

var AudioContext = window.AudioContext || window.webkitAudioContext
if (!AudioContext) return
if (!this.audioContext) {
  this.audioContext = new AudioContext()
}
if (this.audioContext.state === 'suspended') {
  this.audioContext.resume().catch(function () {})
}
var analyser = this.audioContext.createAnalyser()
analyser.fftSize = 512
analyser.smoothingTimeConstant = .56

fftSize=512 会产生 256 个频率采样点,对当前这种横向波形已经足够;数值过大只会增加每帧计算量。 smoothingTimeConstant 用于降低频谱抖动。

然后优先连接当前 <audio> 元素:

this.analyserSource = this.audioContext.createMediaElementSource(source)
this.analyserSource.connect(analyser)
analyser.connect(this.audioContext.destination)

MediaElementSource 会持续跟随同一个 <audio> 元素,即使切换歌曲时 src 发生变化,也不需要为每一首歌重新创建。

项目还保留了 captureStream() 作为兼容后备。如果浏览器和音频来源都不允许读取真实数据,则继续绘制轻量模拟波形,页面不会出现空白。

不要重复对同一个 <audio> 元素调用 createMediaElementSource() 。浏览器通常只允许一个媒体元素绑定一个来源节点,所以代码会复用已经创建的 analyserSource

#第 5 步:处理跨域音频

创建全局音频元素时加入:

source = t.createChild(t.player.options.type, events)
if (isToolPlayer && t.player.options.type === 'audio') {
  source.attr('crossorigin', 'anonymous')
}

必须在给音频设置 src 之前添加 crossorigin 。但这个属性只是告诉浏览器发起匿名跨域请求,音乐服务器仍然必须返回允许跨域的响应头。

如果音乐能播放但频谱没有真实响应,请依次检查:

  1. 浏览器控制台是否出现 CORS 报错;
  2. 音频响应是否包含合适的 Access-Control-Allow-Origin
  3. 请求是否被重定向到另一个不允许跨域的域名;
  4. 页面是否经过用户点击后才开始播放,以便恢复暂停状态的 AudioContext

#第 6 步:计算真实音乐能量

每一帧读取频率和时域数据:

this.analyser.getByteFrequencyData(this.frequencyData)
this.analyser.getByteTimeDomainData(this.timeData)

当前实现会计算三类值:

  • audioEnergy :通过时域 RMS 和低频平均值估算整体响度;
  • bassEnergy :提取前约 9% 的低频采样,增强鼓点反应;
  • impactEnergy :比较当前能量和缓慢变化的基线,突出突然上升的瞬态。

为了避免曲线忽高忽低,上升和下降使用不同缓动速度:

var impactEase = impactTarget > this.impactEnergy ? .64 : .055
var energyEase = energyTarget > this.audioEnergy ? .42 : .11
var bassEase = bassAverage > this.bassEnergy ? .5 : .14
this.audioEnergy += (energyTarget - this.audioEnergy) * energyEase
this.bassEnergy += (bassAverage - this.bassEnergy) * bassEase
this.impactEnergy += (impactTarget - this.impactEnergy) * impactEase

这样波峰可以快速响应鼓点,波谷则更自然地回落。

#第 7 步:绘制多层平滑波形

桌面端使用 56 个控制点,窄屏使用 34 个控制点:

var count = width < 600 ? 34 : 56

三层波形使用不同基线、幅度、速度和透明度:

var layers = [
  { baseline: .86, amplitude: .66, phase: .2, speed: 1.18, alpha: .28, line: 1.5 },
  { baseline: .68, amplitude: .82, phase: 1.7, speed: 1.42, alpha: .38, line: 2 },
  { baseline: .5, amplitude: .98, phase: 3.1, speed: 1.66, alpha: .5, line: 2.6 }
]

相邻点之间使用贝塞尔曲线连接,而不是直接使用折线:

ctx.bezierCurveTo(
  control1X,
  control1Y,
  control2X,
  control2Y,
  next[0],
  next[1]
)

颜色使用青色、蓝紫、粉色和橙色的横向渐变,并采用 lighter 混合模式。三个图层叠加后仍然能看到清晰波峰,又不会变成生硬的柱状频谱。

Canvas 会根据设备像素比调整实际像素尺寸,并把 DPR 上限限制为 2,兼顾高清屏清晰度和性能。

#第 8 步:同步播放生命周期

在音频事件中同步可视化状态:

onplay: function () {
  t.parentNode.addClass('playing')
  visualizer.setPlaying(true)
},
onpause: function () {
  t.parentNode.removeClass('playing')
  visualizer.setPlaying(false)
}

播放后启动真实分析,暂停后降低波动。关闭功能时使用 stopSoon() 延迟取消动画帧,使 CSS 淡出先完成,不会突然闪烁。

#第 9 步:添加波浪区域样式

打开 themes/shoka/source/css/_common/outline/header/waves.styl ,为 Canvas 设置与原波浪相同的尺寸和位置:

.music-visualizer {
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 15vh;
  min-height: 3.125rem;
  max-height: 9.375rem;
  z-index: 5;
  opacity: 0;
  pointer-events: none;
  transform: translateY(.75rem) scaleY(.55);
  transform-origin: center bottom;
  transition: opacity .5s ease, transform .6s cubic-bezier(.22, 1, .36, 1), filter .5s ease;
}

可视化播放时隐藏原 SVG 波浪:

#waves.visualizer-enabled.visualizer-playing .music-visualizer {
  opacity: 1;
  transform: translateY(0) scaleY(1);
}
#waves.visualizer-enabled.visualizer-playing .waves {
  opacity: 0;
  filter: blur(.45rem);
  transform: translateY(.75rem) scaleY(.9);
}

这里使用透明度、模糊和缩放共同过渡,所以切换时不会突然消失。

#第 10 步:美化控制按钮

themes/shoka/source/css/_common/components/tags/player.styl 中加入按钮图标和状态点:

.visualizer {
  position: relative;
  &::before {
    @extend .i-chart-area:before;
  }
  &::after {
    content: "";
    position: absolute;
    width: .19rem;
    height: .19rem;
    border-radius: 50%;
    background: var(--grey-5);
  }
  &.active::after {
    background: var(--color-green);
    box-shadow: 0 0 .24rem var(--color-green);
  }
}

灰色圆点表示关闭,绿色圆点表示开启。控制栏中的按钮使用等宽 flex 布局,所以新增按钮后仍能保持均匀间距。

#构建和验证

在博客根目录执行:

hexo clean
hexo generate --bail
hexo server

打开本地网站后按下面顺序检查:

  1. 打开右下角音乐面板,确认可以看到频谱图标;
  2. 点击按钮,状态点应由灰色变为绿色;
  3. 播放音乐,页面顶部原波浪应淡出,彩色波形应淡入;
  4. 观察鼓点或明显强弱变化时,波峰高度是否同步变化;
  5. 暂停音乐,波形应自然回落;
  6. 切换歌曲,确认可视化仍然工作;
  7. 刷新页面,确认开关状态被保留;
  8. 切换日间/夜间模式和移动端尺寸,确认位置没有错位;
  9. 使用 Tab 聚焦按钮,按 Enter 或空格确认可以切换。

#常见问题

#音乐正常播放,但波形只是轻微规律运动

这通常表示真实分析器没有收到有效数据,当前使用的是后备动画。先在控制台检查 CORS,再检查音频请求最终落到的域名是否允许匿名跨域读取。

#点击播放后频谱仍不启动

部分浏览器会把 AudioContext 保持在 suspended 状态,直到用户主动点击页面。不要在页面加载完成后未经交互自动播放,先手动点击播放按钮再测试。

#切换歌曲后频谱停止

优先复用同一个 <audio> 元素和 MediaElementSource ,只替换 src 。不要在每次切歌时重新调用 createMediaElementSource()

#手机端帧率下降

可以继续降低移动端控制点数量,或者把 Canvas 的 DPR 上限从 2 调整为 1.5。不要同时提高 fftSize 、控制点数量和绘制层数。

#开启后看不到页面原来的波浪

这是预期行为。播放可视化时原波浪会淡出,避免两个动画互相遮挡;暂停或关闭可视化后会自动恢复。

#涉及改动的代码

代码卡片按属性名、函数名或带内容校验的具名片段定位,并显示当前源码行号。片段前后插行可自动重新定位;片段本身改变或位置不唯一时会停止生成,核对教程后再更新摘录,避免误引其他功能。

下面只展示本教程实际涉及的代码区间。点击文件名可以查看当前项目中的原始行号;“⋯ 未改动代码已省略 ⋯” 代表文件中间仍有其他代码,合并时不要删除。

行号以当前项目为准,以后继续修改播放器后可能发生偏移。实际定位时优先搜索 visualizersetupAnalysermusic-visualizer 等名称。

#播放器逻辑

themes/shoka/source/js/_app/player.js改动位置:第 8、25–27、353–356、474–487、519–523、1006–1061、1132–1592、1625–1637、1678–1693、1702–1703 行 575 行 · 20.9 KB
themes/shoka/source/js/_app/player.js
    controls: ['mode', 'lyrics', 'backward', 'play-pause', 'forward', 'visualizer', 'volume'],
⋯ 未改动代码已省略 ⋯
      "visualizer": function() {
        visualizer.toggle()
      },
⋯ 未改动代码已省略 ⋯
      if(isToolPlayer) {
        visualizer.available = d !== "none"
        visualizer.syncState()
      }
⋯ 未改动代码已省略 ⋯
    pause: function() {
      this.playRequested = false
      source.pause()
      if(isToolPlayer)
        visualizer.setPlaying(false)
      document.title = originTitle
    },
    stop: function() {
      this.playRequested = false
      source.pause();
      source.currentTime = 0;
      if(isToolPlayer)
        visualizer.setPlaying(false)
      document.title = originTitle;
⋯ 未改动代码已省略 ⋯
    destroy: function() {
      // The tool player survives PJAX. Explicit teardown releases visual work
      // only; its Web Audio graph may also be the audible playback route.
      visualizer.destroy()
    }
⋯ 未改动代码已省略 ⋯
  var controller = {
    el: null,
    btns: {},
    step: 'next',
    create: function () {
      if(!t.player.options.controls)
        return
 
      var that = this
      t.player.options.controls.forEach(function(item) {
        if(that.btns[item])
          return;
 
        var opt = {
          onclick: function(event){
            that.events[item] ? that.events[item](event) : t.player.options.events[item](event)
          }
        }
 
        switch(item) {
          case 'volume':
            opt.className = ' ' + (source.muted ? 'off' : 'on')
            opt.innerHTML = '<div class="bar"></div>'
            opt['on'+utils.nameMap.dragStart] = that.events['volume']
            opt.onclick = null
            break;
          case 'mode':
            opt.className = ' ' + t.player.options.mode
            break;
          default:
            opt.className = ''
            break;
        }
 
        opt.className = item + opt.className + ' btn'
 
        that.btns[item] = that.el.createChild('div', opt)
 
        if(item === 'visualizer' || item === 'lyrics') {
          that.btns[item].tabIndex = 0
          that.btns[item].attr('role', 'button')
          that.btns[item].addEventListener('keydown', function(event) {
            if(event.key === 'Enter' || event.key === ' ') {
              event.preventDefault()
              event.currentTarget.click()
            }
          })
        }
      })
 
      that.btns['volume'].bar = that.btns['volume'].child('.bar')
      if(isToolPlayer) {
        lyrics.syncButton()
        visualizer.syncButton()
      }
    },
⋯ 未改动代码已省略 ⋯
  var visualizer = {
    el: null,
    ctx: null,
    enabled: isToolPlayer && store.get('_PlayerVisualizer') === 'on',
    available: false,
    playing: false,
    raf: null,
    stopTimer: null,
    audioContext: null,
    analyser: null,
    analyserSource: null,
    mediaStream: null,
    frequencyData: null,
    timeData: null,
    levels: [],
    audioEnergy: 0,
    bassEnergy: 0,
    energyBase: 0,
    impactEnergy: 0,
    signalReady: false,
    playingSince: 0,
    lastAnalyserAttempt: 0,
    analyserType: 'fallback',
    lastDiagnosticUpdate: 0,
    motionUnsubscribe: null,
    destroyed: false,
 
    motionReduced: function() {
      return window.ShokaMotion && typeof window.ShokaMotion.reduced === 'function'
        ? window.ShokaMotion.reduced()
        : !!(document.documentElement.dataset && document.documentElement.dataset.motion === 'reduce') ||
          !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
    },
 
    bindMotion: function() {
      if(!isToolPlayer || this.motionUnsubscribe || this.destroyed)
        return
      var that = this
      var refresh = function() { that.syncState() }
      if(window.ShokaMotion && typeof window.ShokaMotion.subscribe === 'function') {
        this.motionUnsubscribe = window.ShokaMotion.subscribe(refresh)
      } else {
        var query = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)')
        document.addEventListener('shoka:display-change', refresh)
        if(query && query.addEventListener) query.addEventListener('change', refresh)
        else if(query && query.addListener) query.addListener(refresh)
        this.motionUnsubscribe = function() {
          document.removeEventListener('shoka:display-change', refresh)
          if(query && query.removeEventListener) query.removeEventListener('change', refresh)
          else if(query && query.removeListener) query.removeListener(refresh)
        }
      }
      this.syncState()
    },
 
    ensureCanvas: function() {
      if(!isToolPlayer)
        return false
 
      var waves = $('#waves')
      if(!waves)
        return false
 
      if(!this.el) {
        this.el = waves.child('.music-visualizer')
        if(!this.el) {
          this.el = waves.createChild('canvas', {
            className: 'music-visualizer'
          })
          this.el.attr('aria-hidden', 'true')
        }
        this.ctx = this.el.getContext('2d')
      }
 
      return !!this.ctx
    },
 
    syncButton: function() {
      if(!isToolPlayer || !controller.btns.visualizer)
        return
 
      var button = controller.btns.visualizer
      button.toggleClass('active', this.enabled)
      button.attr('aria-pressed', this.enabled ? 'true' : 'false')
      button.attr('title', this.enabled && this.motionReduced()
        ? '音效可视化已开启,减弱动效下暂停;点击关闭'
        : this.enabled ? '关闭音效可视化' : '开启音效可视化')
    },
 
    syncState: function() {
      if(!isToolPlayer || this.destroyed)
        return
 
      var waves = $('#waves')
      var reducedMotion = this.motionReduced()
      var active = this.enabled && this.available && this.playing && !reducedMotion
      this.syncButton()
 
      if(active && this.ensureCanvas()) {
        waves.addClass('visualizer-enabled visualizer-playing')
        this.start()
        this.setupAnalyser()
      } else {
        waves && waves.removeClass('visualizer-enabled visualizer-playing')
        if(reducedMotion) this.stop()
        else this.stopSoon()
      }
    },
 
    toggle: function() {
      if(!isToolPlayer)
        return
 
      this.enabled = !this.enabled
      if(this.enabled) {
        this.signalReady = false
        store.set('_PlayerVisualizer', 'on')
      } else {
        store.del('_PlayerVisualizer')
      }
      this.syncState()
      showtip(this.enabled ? '音效可视化已开启' : '音效可视化已关闭')
    },
 
    setPlaying: function(status) {
      if(!isToolPlayer)
        return
 
      this.playing = !!status
      if(this.playing) {
        this.playingSince = Date.now()
      } else {
        this.audioEnergy = 0
        this.bassEnergy = 0
        this.energyBase = 0
        this.impactEnergy = 0
      }
 
      this.syncState()
    },
 
    setupAnalyser: function() {
      if(this.destroyed || this.motionReduced() || this.analyser || !source || !this.enabled || !this.playing)
        return
 
      var AudioContext = window.AudioContext || window.webkitAudioContext
      if(!AudioContext)
        return
 
      this.lastAnalyserAttempt = Date.now()
 
      try {
        if(!this.audioContext)
          this.audioContext = new AudioContext()
 
        if(this.audioContext.state === 'suspended')
          this.audioContext.resume().catch(function() {})
 
        var analyser = this.audioContext.createAnalyser()
        analyser.fftSize = 512
        analyser.smoothingTimeConstant = .56
 
        // The audio element is created with anonymous CORS before its src is set,
        // so cross-origin songs with ACAO headers can provide real PCM samples.
        // MediaElementSource remains attached when src changes and must be reused.
        try {
          this.analyserSource = this.audioContext.createMediaElementSource(source)
          this.analyserSource.connect(analyser)
          analyser.connect(this.audioContext.destination)
          this.analyserType = 'media-element'
        } catch(error) {}
 
        // captureStream is a compatibility fallback. Some browsers end its
        // audio track when src changes, therefore it must not be the first choice.
        var streamMethod = source.captureStream || source.mozCaptureStream
        if(!this.analyserSource && streamMethod) {
          this.mediaStream = streamMethod.call(source)
          var stream = this.mediaStream
          if(stream && stream.getAudioTracks && stream.getAudioTracks().length) {
            this.analyserSource = this.audioContext.createMediaStreamSource(stream)
            this.analyserSource.connect(analyser)
            this.analyserType = 'media-stream'
          }
        }
 
        if(this.analyserSource) {
          this.analyser = analyser
          this.frequencyData = new Uint8Array(analyser.frequencyBinCount)
          this.timeData = new Uint8Array(analyser.fftSize)
        }
      } catch(error) {
        this.analyser = null
        this.analyserSource = null
        this.analyserType = 'fallback-' + (error && error.name ? error.name.toLowerCase() : 'error')
      }
    },
 
    resize: function() {
      if(!this.el || !this.ctx)
        return null
 
      var rect = this.el.getBoundingClientRect()
      if(!rect.width || !rect.height)
        return null
 
      var dpr = Math.min(window.devicePixelRatio || 1, 2)
      var width = Math.round(rect.width * dpr)
      var height = Math.round(rect.height * dpr)
      if(this.el.width !== width || this.el.height !== height) {
        this.el.width = width
        this.el.height = height
        this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
      }
 
      return {
        width: rect.width,
        height: rect.height
      }
    },
 
    getLevel: function(index, count, time) {
      var target = 0
      var volume = source && !source.muted ? source.volume : 0
 
      if(this.analyser && this.frequencyData) {
        var ratio = (index + 1) / count
        var bin = Math.min(this.frequencyData.length - 1, Math.floor(Math.pow(ratio, 1.45) * this.frequencyData.length * .72))
        var previousBin = Math.max(0, bin - 1)
        var nextBin = Math.min(this.frequencyData.length - 1, bin + 1)
        var rawLevel = Math.max(0, (this.frequencyData[previousBin] + this.frequencyData[bin] * 2 + this.frequencyData[nextBin]) / 4 - 3)
        var bandEnergy = Math.pow(rawLevel / 225, .72)
        target = Math.min(1.35, bandEnergy * .92 + this.audioEnergy * .32 + this.bassEnergy * .28 + this.impactEnergy * .45) * volume
      }
 
      if(!this.signalReady && this.playing && volume > 0 && Date.now() - this.playingSince > 500) {
        var phase = time * (3.4 + index % 5 * .12) + index * .72
        var beat = Math.pow(Math.max(0, Math.sin(time * 3.7)), 6) * .34
        target = (.12 + (Math.sin(phase) + 1) * .13 + beat) * volume
      }
 
      if(!this.playing || volume === 0)
        target = .025 + (Math.sin(time * 1.25 + index * .38) + 1) * .012
 
      var current = this.levels[index] || 0
      var easing = target > current ? .46 : .14
      current += (target - current) * easing
      this.levels[index] = current
      return current
    },
 
    draw: function() {
      if(this.destroyed || this.motionReduced()) {
        this.stop()
        return
      }
      var that = this
      var size = this.resize()
      if(!size || !this.ctx) {
        this.raf = window.requestAnimationFrame(function() {
          that.draw()
        })
        return
      }
 
      var ctx = this.ctx
      var width = size.width
      var height = size.height
      var count = width < 600 ? 34 : 56
      var time = performance.now() / 1000
      var levels = []
 
      if(this.playing && !this.analyser && Date.now() - this.lastAnalyserAttempt > 900)
        this.setupAnalyser()
 
      if(this.analyser && this.frequencyData) {
        this.analyser.getByteFrequencyData(this.frequencyData)
        if(this.timeData)
          this.analyser.getByteTimeDomainData(this.timeData)
        var signalTotal = 0
        var signalPeak = 0
        var signalEnd = Math.floor(this.frequencyData.length * .72)
        var bassEnd = Math.max(5, Math.floor(this.frequencyData.length * .09))
        var bassTotal = 0
        for(var signalIndex = 2; signalIndex < signalEnd; signalIndex++) {
          var signalValue = this.frequencyData[signalIndex]
          signalTotal += signalValue
          signalPeak = Math.max(signalPeak, signalValue)
          if(signalIndex < bassEnd)
            bassTotal += signalValue
        }
        var signalAverage = signalTotal / Math.max(1, signalEnd - 2)
        var bassAverage = bassTotal / Math.max(1, bassEnd - 2) / 255
        var squareTotal = 0
        if(this.timeData) {
          for(var timeIndex = 0; timeIndex < this.timeData.length; timeIndex++) {
            var sample = (this.timeData[timeIndex] - 128) / 128
            squareTotal += sample * sample
          }
        }
        var rms = this.timeData ? Math.sqrt(squareTotal / this.timeData.length) : signalAverage / 255
        var energyTarget = Math.min(1, rms * 3.15 + bassAverage * .72)
        var energyRise = Math.max(0, energyTarget - this.audioEnergy)
        var bassRise = Math.max(0, bassAverage - this.bassEnergy)
        this.energyBase += (energyTarget - this.energyBase) * (energyTarget > this.energyBase ? .028 : .014)
        var impactTarget = Math.min(1.2, Math.max(0, energyTarget - this.energyBase) * 2.8 + energyRise * 3.6 + bassRise * 5.2)
        var impactEase = impactTarget > this.impactEnergy ? .64 : .055
        var energyEase = energyTarget > this.audioEnergy ? .42 : .11
        var bassEase = bassAverage > this.bassEnergy ? .5 : .14
        this.audioEnergy += (energyTarget - this.audioEnergy) * energyEase
        this.bassEnergy += (bassAverage - this.bassEnergy) * bassEase
        this.impactEnergy += (impactTarget - this.impactEnergy) * impactEase
        if(signalPeak > 12 && (signalAverage > 1.5 || rms > .008))
          this.signalReady = true
      }
 
      if(Date.now() - this.lastDiagnosticUpdate > 500) {
        var waves = $('#waves')
        if(waves) {
          waves.attr('data-visualizer-source', this.analyserType)
          waves.attr('data-visualizer-reactive', this.signalReady ? 'true' : 'false')
          waves.attr('data-visualizer-energy', this.audioEnergy.toFixed(3))
          waves.attr('data-visualizer-bass', this.bassEnergy.toFixed(3))
          waves.attr('data-visualizer-impact', this.impactEnergy.toFixed(3))
        }
        this.lastDiagnosticUpdate = Date.now()
      }
 
      ctx.clearRect(0, 0, width, height)
      for(var i = 0; i < count; i++) {
        var level = this.getLevel(i, count, time)
        var edge = Math.sin(Math.PI * (i + .5) / count)
        levels.push(level * (.52 + edge * .72))
      }
 
      var layers = [
        { baseline: .86, amplitude: .66, phase: .2, speed: 1.18, alpha: .28, line: 1.5 },
        { baseline: .68, amplitude: .82, phase: 1.7, speed: 1.42, alpha: .38, line: 2 },
        { baseline: .5, amplitude: .98, phase: 3.1, speed: 1.66, alpha: .5, line: 2.6 }
      ]
 
      var trace = function(points) {
        ctx.beginPath()
        ctx.moveTo(points[0][0], points[0][1])
        for(var pointIndex = 0; pointIndex < points.length - 1; pointIndex++) {
          var previous = points[pointIndex - 1] || points[pointIndex]
          var current = points[pointIndex]
          var next = points[pointIndex + 1]
          var following = points[pointIndex + 2] || next
          var control1X = current[0] + (next[0] - previous[0]) / 6
          var control1Y = current[1] + (next[1] - previous[1]) / 6
          var control2X = next[0] - (following[0] - current[0]) / 6
          var control2Y = next[1] - (following[1] - current[1]) / 6
          ctx.bezierCurveTo(control1X, control1Y, control2X, control2Y, next[0], next[1])
        }
      }
 
      layers.forEach(function(layer, layerIndex) {
        var points = []
        var reactive = that.signalReady && that.playing
        var audioMotion = reactive ? that.audioEnergy : 0
        var bassMotion = reactive ? that.bassEnergy : 0
        var impactMotion = reactive ? that.impactEnergy : 0
        // Keep the horizontal travel calm and continuous. Audio energy changes
        // the height, while only adding a very small bounded phase offset.
        var phaseTime = time * layer.speed * .48 + bassMotion * .16 + impactMotion * .1
        for(var index = 0; index < count; index++) {
          var x = index / (count - 1) * width
          var oscillation = Math.sin(index * .42 + phaseTime + layer.phase)
          var sharpness = 1 + layerIndex * .04
          var sharpOscillation = Math.sign(oscillation) * Math.pow(Math.abs(oscillation), sharpness)
          var bandMotion = reactive
            ? Math.max(0, levels[index] - .12) * 1.28
            : levels[index] * .72
          var response = Math.min(1.55, bandMotion + audioMotion * .36 + bassMotion * (.2 + layerIndex * .07) + impactMotion * .86)
          var pulse = reactive ? .68 + audioMotion * .82 + impactMotion * 1.18 : 1
          var energy = (reactive ? .006 : .028) + response * pulse * 1.02
          var room = sharpOscillation >= 0 ? layer.baseline - .085 : .915 - layer.baseline
          var amplitudeScale = Math.min(.9, .08 + energy * layer.amplitude * .27)
          var displacement = room * amplitudeScale * Math.abs(sharpOscillation)
          var detailStrength = reactive ? Math.min(.012, .002 + audioMotion * .009 + impactMotion * .009) : .005
          var detail = Math.sin(index * .12 - phaseTime * .55 + layer.phase) * height * detailStrength
          var y = height * (layer.baseline - Math.sign(sharpOscillation) * displacement) + detail
          y = Math.max(height * .055, Math.min(height * .945, y))
          points.push([x, y])
        }
 
        var gradient = ctx.createLinearGradient(0, 0, width, 0)
        gradient.addColorStop(0, 'rgba(86, 184, 255, .08)')
        gradient.addColorStop(.18, 'rgba(63, 224, 238, .94)')
        gradient.addColorStop(.43, 'rgba(118, 142, 255, .98)')
        gradient.addColorStop(.66, 'rgba(190, 101, 255, .98)')
        gradient.addColorStop(.84, 'rgba(255, 98, 181, .94)')
        gradient.addColorStop(1, 'rgba(255, 199, 91, .08)')
 
        ctx.save()
        ctx.globalCompositeOperation = 'lighter'
        trace(points)
        ctx.lineTo(width, height)
        ctx.lineTo(0, height)
        ctx.closePath()
        ctx.globalAlpha = layer.alpha
        ctx.fillStyle = gradient
        ctx.fill()
 
        trace(points)
        ctx.globalAlpha = Math.min(1, layer.alpha + .34)
        ctx.strokeStyle = gradient
        ctx.lineWidth = layer.line
        ctx.shadowColor = layerIndex === 2 ? 'rgba(202, 108, 255, .78)' : 'rgba(72, 210, 255, .55)'
        ctx.shadowBlur = 12 + layerIndex * 5
        ctx.stroke()
        ctx.restore()
      })
 
      this.raf = window.requestAnimationFrame(function() {
        that.draw()
      })
    },
 
    start: function() {
      var that = this
      window.clearTimeout(this.stopTimer)
      this.stopTimer = null
      if(this.destroyed || this.motionReduced() || this.raf || !this.ensureCanvas())
        return
 
      this.raf = window.requestAnimationFrame(function() {
        that.draw()
      })
    },
 
    stop: function() {
      window.clearTimeout(this.stopTimer)
      this.stopTimer = null
      if(this.raf !== null) window.cancelAnimationFrame(this.raf)
      this.raf = null
      if(this.ctx && this.el)
        this.ctx.clearRect(0, 0, this.el.width, this.el.height)
      // Do not pause the audio, suspend its context or disconnect its analyser.
    },
 
    stopSoon: function() {
      var that = this
      window.clearTimeout(this.stopTimer)
      this.stopTimer = window.setTimeout(function() {
        that.stopTimer = null
        if(!that.destroyed && !that.motionReduced() && that.enabled && that.available && that.playing)
          return
        that.stop()
      }, 650)
    },
 
    destroy: function() {
      this.destroyed = true
      if(this.motionUnsubscribe) this.motionUnsubscribe()
      this.motionUnsubscribe = null
      this.stop()
      var waves = isToolPlayer && $('#waves')
      waves && waves.removeClass('visualizer-enabled visualizer-playing')
    }
  }
⋯ 未改动代码已省略 ⋯
    onplay: function() {
      t.parentNode.addClass('playing')
      visualizer.setPlaying(true)
      lyrics.syncOverlay()
      showtip(this.attr('title'), true)
      NOWPLAYING = t
    },
    onpause: function() {
      t.parentNode.removeClass('playing')
      visualizer.setPlaying(false)
      lyrics.syncOverlay()
      NOWPLAYING = null
    },
⋯ 未改动代码已省略 ⋯
    t.player.options = Object.assign(option, config);
    if(!isToolPlayer && t.player.options.controls)
      t.player.options.controls = t.player.options.controls.filter(function(item) {
        return item !== 'visualizer' && item !== 'lyrics'
      })
    t.player.options.mode = store.get('_PlayerMode') || t.player.options.mode
 
    // 初始化button、controls以及click事件
    buttons.create()
 
    // 初始化audio or video
    source = t.createChild(t.player.options.type, events);
    // Web Audio may only analyse cross-origin media when anonymous CORS is set
    // before src. Current Meting audio redirects return ACAO for Origin requests.
    if(isToolPlayer && t.player.options.type === 'audio')
      source.crossOrigin = 'anonymous'
⋯ 未改动代码已省略 ⋯
  init(config)
  visualizer.bindMotion()

#波浪区域样式

themes/shoka/source/css/_common/outline/header/waves.styl改动位置:第 1–79 行 79 行 · 1.9 KB
themes/shoka/source/css/_common/outline/header/waves.styl
.waves {
  width: 100%;
  height: 15vh;
  bottom: 0;
  //margin-bottom: -.6875rem;
  min-height: 3.125rem;
  max-height: 9.375rem;
  //position:relative;
  position: absolute;
  z-index: 4;
  opacity: 1;
  transform-origin: center bottom;
  transition: opacity .45s ease, filter .45s ease, transform .55s cubic-bezier(.22, 1, .36, 1);
 
  +mobile() {
    height: 10vh;
  }
}
 
.music-visualizer {
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 15vh;
  min-height: 3.125rem;
  max-height: 9.375rem;
  z-index: 5;
  opacity: 0;
  pointer-events: none;
  filter: blur(.18rem) saturate(1.15);
  mix-blend-mode: screen;
  transform: translateY(.75rem) scaleY(.55);
  transform-origin: center bottom;
  -webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 7%, #000 93%, transparent 100%);
  mask-image: linear-gradient(90deg, transparent 0%, #000 7%, #000 93%, transparent 100%);
  transition: opacity .5s ease, transform .6s cubic-bezier(.22, 1, .36, 1), filter .5s ease;
  will-change: opacity, transform, filter;
 
  +mobile() {
    height: 10vh;
  }
}
 
#waves.visualizer-enabled.visualizer-playing .music-visualizer {
  opacity: 1;
  filter: blur(0) saturate(1.42) drop-shadow(0 0 .75rem unquote('rgba(var(--palette-primary-light-rgb, 167, 105, 255), .48)'));
  transform: translateY(0) scaleY(1);
}
 
#waves.visualizer-enabled.visualizer-playing .waves {
  opacity: 0;
  filter: blur(.45rem);
  transform: translateY(.75rem) scaleY(.9);
}
 
[data-theme="dark"] #waves.visualizer-enabled.visualizer-playing .music-visualizer {
  opacity: .92;
}
 
@media (prefers-reduced-motion: reduce) {
  .music-visualizer {
    transition-duration: .2s;
  }
 
  .waves {
    transition-duration: .2s;
  }
 
}
/* Animation */
.parallax>use {
  animation: wave 25s cubic-bezier(.55, .5, .45, .5) infinite;
}
 
.parallax>use:nth-child(1) {
  animation-delay: -2s;
  animation-duration: 7s;
  fill: var(--grey-1-a7);

#可视化按钮样式

themes/shoka/source/css/_common/components/tags/player.styl改动位置:第 27–50、161–213 行 77 行 · 1.9 KB
themes/shoka/source/css/_common/components/tags/player.styl
  .controller {
    font-family-icons();
    cursor: pointer;
    font-size: $font-size-larger;
    display: flex;
    justify-content: space-around;
    align-items: center;
    text-align: center;
 
    .btn {
      color: var(--grey-6);
      flex: 1 1 0;
      width: auto;
      min-width: 0;
      padding: .35rem 0;
      border-radius: .5rem;
      the-transition(.25s, ease-out);
 
      &:hover {
        color: var(--color-pink);
        background: var(--grey-1-a5);
      }
    }
  }
⋯ 未改动代码已省略 ⋯
  .visualizer {
    position: relative;
 
    &::before {
      @extend .i-chart-area:before;
      display: inline-block;
      font-size: .82em;
      opacity: .84;
      transform: scaleX(.88);
      transform-origin: center;
      -webkit-font-smoothing: antialiased;
    }
 
    &::after {
      content: "";
      position: absolute;
      width: .19rem;
      height: .19rem;
      right: 28%;
      bottom: .34rem;
      border-radius: 50%;
      background: var(--grey-5);
      opacity: .68;
      transform: scale(.82);
      transition: opacity .25s ease, transform .25s ease, background .25s ease, box-shadow .25s ease;
    }
 
    &.active {
      color: var(--palette-primary-ink, #a76cff);
      background: linear-gradient(135deg, unquote('rgba(var(--palette-secondary-rgb, 63, 220, 238), .06)'), unquote('rgba(var(--palette-primary-light-rgb, 185, 101, 255), .08)'), unquote('rgba(var(--palette-primary-light-rgb, 255, 99, 181), .05)'));
      text-shadow: 0 0 .24rem unquote('rgba(var(--palette-primary-light-rgb, 168, 103, 255), .38)');
 
      &::before {
        opacity: .94;
      }
 
      &::after {
        background: var(--color-green);
        opacity: 1;
        transform: scale(1);
        box-shadow: 0 0 .24rem var(--color-green);
      }
    }
 
    &:focus {
      outline: none;
    }
 
    &:focus-visible {
      outline: .1rem solid unquote('rgba(var(--palette-primary-light-rgb, 184, 121, 255), .72)');
      outline-offset: .08rem;
    }
  }

完成!现在右下角播放器已经拥有可切换、能记忆状态并响应真实音乐内容的彩色音效可视化。

更新于 阅读次数

请我喝[茶]~( ̄▽ ̄)~*

Lavender 微信支付

微信支付

Lavender 支付宝

支付宝