为 Shoka 右下角音乐播放器增加音效可视化开关,使用 Web Audio API 分析真实音乐能量,并在页面顶部原有波浪区域绘制彩色、平滑且随节奏起伏的音频波形。
#前言
Shoka 自带的音乐播放器可以播放歌单,但默认页面波浪只是固定动画,与当前音乐内容没有关系。本次改造会在播放器内部加入一个音效可视化按钮:开启后,原有 SVG 波浪自然淡出,Canvas 彩色波形接替显示;暂停或关闭功能后,页面再平滑恢复原来的波浪。
最终效果包含:
- 可在音乐面板中实时开启或关闭;
- 开关状态保存到浏览器,刷新页面后仍然保留;
- 优先读取真实频率、低频能量和瞬时冲击;
- 波峰和波谷均会跟随音乐起伏,而不是固定循环;
- 使用多层贝塞尔曲线绘制圆滑波形;
- 开启可视化并播放音乐时,原有 SVG 波浪自然淡出;
- 暂停、切歌、PJAX 切换和窗口缩放后仍能继续工作;
- 无法取得真实音频数据时提供柔和的后备动画;
- 支持键盘操作、日间/夜间模式和减少动态效果设置。
#开始前准备
第一次修改主题时,建议先完成下面几项:
- 找到博客根目录。能看到
_config.yml、package.json、source和themes的目录就是根目录; - 备份
themes/shoka/source/js/_app/player.js以及本教程涉及的两个 Stylus 文件,或者先提交一次 Git; - 确认主题配置中的
audio已经可以正常加载并播放音乐; - 在博客根目录执行一次
npm install,确保原项目依赖完整; - 本功能不需要安装新的 npm 包,浏览器原生的 Web Audio API 和 Canvas 已经足够;
- 建议通过
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.js 或 public/css/app.css ,它们会在下一次执行 hexo clean 时被重新生成。
#实现原理
整个过程可以理解为下面五步:
- 用户点击播放器中的可视化按钮;
- 程序创建覆盖在
#waves区域上的 Canvas; AudioContext和AnalyserNode从当前<audio>元素读取频率及波形数据;- 每一帧计算整体能量、低频能量、冲击能量和多个频段的高度;
- 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 。但这个属性只是告诉浏览器发起匿名跨域请求,音乐服务器仍然必须返回允许跨域的响应头。
如果音乐能播放但频谱没有真实响应,请依次检查:
- 浏览器控制台是否出现 CORS 报错;
- 音频响应是否包含合适的
Access-Control-Allow-Origin; - 请求是否被重定向到另一个不允许跨域的域名;
- 页面是否经过用户点击后才开始播放,以便恢复暂停状态的
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 |
打开本地网站后按下面顺序检查:
- 打开右下角音乐面板,确认可以看到频谱图标;
- 点击按钮,状态点应由灰色变为绿色;
- 播放音乐,页面顶部原波浪应淡出,彩色波形应淡入;
- 观察鼓点或明显强弱变化时,波峰高度是否同步变化;
- 暂停音乐,波形应自然回落;
- 切换歌曲,确认可视化仍然工作;
- 刷新页面,确认开关状态被保留;
- 切换日间/夜间模式和移动端尺寸,确认位置没有错位;
- 使用 Tab 聚焦按钮,按 Enter 或空格确认可以切换。
#常见问题
#音乐正常播放,但波形只是轻微规律运动
这通常表示真实分析器没有收到有效数据,当前使用的是后备动画。先在控制台检查 CORS,再检查音频请求最终落到的域名是否允许匿名跨域读取。
#点击播放后频谱仍不启动
部分浏览器会把 AudioContext 保持在 suspended 状态,直到用户主动点击页面。不要在页面加载完成后未经交互自动播放,先手动点击播放按钮再测试。
#切换歌曲后频谱停止
优先复用同一个 <audio> 元素和 MediaElementSource ,只替换 src 。不要在每次切歌时重新调用 createMediaElementSource() 。
#手机端帧率下降
可以继续降低移动端控制点数量,或者把 Canvas 的 DPR 上限从 2 调整为 1.5。不要同时提高 fftSize 、控制点数量和绘制层数。
#开启后看不到页面原来的波浪
这是预期行为。播放可视化时原波浪会淡出,避免两个动画互相遮挡;暂停或关闭可视化后会自动恢复。
#涉及改动的代码
代码卡片按属性名、函数名或带内容校验的具名片段定位,并显示当前源码行号。片段前后插行可自动重新定位;片段本身改变或位置不唯一时会停止生成,核对教程后再更新摘录,避免误引其他功能。
下面只展示本教程实际涉及的代码区间。点击文件名可以查看当前项目中的原始行号;“⋯ 未改动代码已省略 ⋯” 代表文件中间仍有其他代码,合并时不要删除。
行号以当前项目为准,以后继续修改播放器后可能发生偏移。实际定位时优先搜索 visualizer 、 setupAnalyser 、 music-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 行
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 行
.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 行
.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; | |
} | |
} |
完成!现在右下角播放器已经拥有可切换、能记忆状态并响应真实音乐内容的彩色音效可视化。