// ==UserScript==
// @name Bilibili 视频音量均衡器
// @namespace http://tampermonkey.net/
// @version 0.1.2
// @description 通过 Web Audio API 压缩 Bilibili 视频中音频的动态范围,使不同视频或同一视频中差距过大的响度保持一致
// @author Timothy Tao & Github Copilot
// @match *://www.bilibili.com/video/*
// @match *://www.bilibili.com/bangumi/play/*
// @match *://live.bilibili.com/*
// @match *://www.bilibili.com/list/*
// @icon https://www.bilibili.com/favicon.ico
// @grant none
// @run-at document-start
// @license MIT
// @downloadURL https://update.greasyfork.icu/scripts/557295/Bilibili%20%E8%A7%86%E9%A2%91%E9%9F%B3%E9%87%8F%E5%9D%87%E8%A1%A1%E5%99%A8.user.js
// @updateURL https://update.greasyfork.icu/scripts/557295/Bilibili%20%E8%A7%86%E9%A2%91%E9%9F%B3%E9%87%8F%E5%9D%87%E8%A1%A1%E5%99%A8.meta.js
// ==/UserScript==
(function() {
'use strict';
console.log('[Bilibili Loudness Equalizer] Script started.');
let audioCtx;
let sourceNode;
let compressorNode;
let gainNode;
let currentVideoElement = null;
let isEnabled = true; // 默认开启
// 添加样式
function addStyles() {
const style = document.createElement('style');
style.textContent = `
@keyframes bili-eq-bounce {
0% { transform: scaleY(1); }
50% { transform: scaleY(1.6); }
100% { transform: scaleY(1); }
}
.bili-loudness-btn {
color: hsla(0,0%,100%,.8); /* Bilibili 默认图标颜色 */
transition: color 0.3s;
}
.bili-loudness-btn:hover {
color: #fff;
}
.bili-loudness-btn.active {
color: #00a1d6 !important; /* 开启时蓝色 */
}
.bili-loudness-btn svg {
fill: currentColor; /* 跟随文字颜色 */
}
.bili-loudness-btn .bar {
transform-origin: center bottom; /* 底部对齐缩放 */
transform-box: fill-box; /* 确保变换基于路径自身 */
}
.bili-loudness-btn.animating .bar {
animation: bili-eq-bounce 0.4s ease-in-out;
}
.bili-loudness-btn.animating .bar-1 { animation-delay: 0s; }
.bili-loudness-btn.animating .bar-2 { animation-delay: 0.1s; }
.bili-loudness-btn.animating .bar-3 { animation-delay: 0.2s; }
`;
document.head.appendChild(style);
}
// 图标 SVG (分离的波形图)
const iconSvg = `
`;
// 尝试添加控制按钮到播放器控制栏
function tryAddControlBtn() {
// 如果按钮已存在,直接返回
if (document.querySelector('.bili-loudness-btn')) return;
// 查找控制栏右侧容器 (兼容新旧版播放器)
const rightControl = document.querySelector('.bpx-player-control-bottom-right') ||
document.querySelector('.bilibili-player-video-control-bottom-right');
if (rightControl) {
const btn = document.createElement('div');
btn.className = 'bpx-player-ctrl-btn bili-loudness-btn';
btn.style.cssText = 'display: inline-flex; align-items: center; justify-content: center; cursor: pointer; margin-right: 8px;';
btn.innerHTML = iconSvg;
// 添加提示 (Tooltip)
btn.setAttribute('aria-label', '音量均衡');
btn.title = isEnabled ? '音量均衡: 开' : '音量均衡: 关';
btn.onclick = () => {
isEnabled = !isEnabled;
updateBtnState(btn);
updateAudioGraph();
// 触发动画
btn.classList.remove('animating');
void btn.offsetWidth; // 触发重绘
btn.classList.add('animating');
};
// 插入位置:优先放在音量前面,否则直接追加到末尾
const anchor = rightControl.querySelector('.bpx-player-ctrl-volume') ||
rightControl.querySelector('.bilibili-player-video-btn-volume');
if (anchor) {
rightControl.insertBefore(btn, anchor);
} else {
rightControl.appendChild(btn);
}
updateBtnState(btn);
console.log('[Bilibili Loudness Equalizer] Control button added.');
}
}
// 更新按钮状态 (颜色/提示)
function updateBtnState(btnElement) {
const btn = btnElement || document.querySelector('.bili-loudness-btn');
if (!btn) return;
if (isEnabled) {
btn.classList.add('active');
btn.title = '音量均衡: 开';
} else {
btn.classList.remove('active');
btn.title = '音量均衡: 关';
}
}
// 更新音频连接图
function updateAudioGraph() {
if (!sourceNode || !audioCtx) {
console.warn('[Bilibili Loudness Equalizer] Cannot update audio graph: source or context missing');
return;
}
try {
// 先断开所有连接
sourceNode.disconnect();
} catch (e) {
// 忽略断开连接时的错误
}
try {
if (isEnabled) {
// 开启模式:Source -> Compressor -> Gain -> Destination
// Compressor -> Gain -> Destination 已经在 initAudioContext 中连接好了
// 这里只需要连接 Source -> Compressor
sourceNode.connect(compressorNode);
console.log('[Bilibili Loudness Equalizer] Enabled: Source -> Compressor -> Gain -> Destination');
} else {
// 关闭模式:Source -> Destination (直通)
sourceNode.connect(audioCtx.destination);
console.log('[Bilibili Loudness Equalizer] Disabled: Source -> Destination (bypass)');
}
} catch (err) {
console.error('[Bilibili Loudness Equalizer] Error updating audio graph:', err);
// 如果连接失败,至少确保有声音输出(直通模式)
try {
sourceNode.disconnect();
sourceNode.connect(audioCtx.destination);
console.log('[Bilibili Loudness Equalizer] Fallback to direct connection');
} catch (fallbackErr) {
console.error('[Bilibili Loudness Equalizer] Fallback connection failed:', fallbackErr);
}
}
// 确保按钮状态同步
updateBtnState();
}
// 初始化 AudioContext
function initAudioContext() {
if (!audioCtx) {
const AudioContext = window.AudioContext || window.webkitAudioContext;
audioCtx = new AudioContext();
// 创建压缩器节点 (DynamicsCompressorNode)
// 作用:降低大音量的部分,保留小音量的部分,从而减小动态范围
compressorNode = audioCtx.createDynamicsCompressor();
compressorNode.threshold.setValueAtTime(-50, audioCtx.currentTime); // 阈值:超过 -50dB 开始压缩
compressorNode.knee.setValueAtTime(40, audioCtx.currentTime); // 拐点:平滑过渡
compressorNode.ratio.setValueAtTime(12, audioCtx.currentTime); // 比率:压缩比 12:1
compressorNode.attack.setValueAtTime(0, audioCtx.currentTime); // 启动时间:立即响应
compressorNode.release.setValueAtTime(0.25, audioCtx.currentTime); // 释放时间
// 创建增益节点 (GainNode)
// 作用:因为压缩器降低了整体音量,需要用增益把音量补回来 (Makeup Gain)
gainNode = audioCtx.createGain();
gainNode.gain.setValueAtTime(1.0, audioCtx.currentTime); // 初始增益,可以根据需要调整,例如 2.0 或 3.0
// 连接处理链: Compressor -> Gain -> Destination (扬声器)
compressorNode.connect(gainNode);
gainNode.connect(audioCtx.destination);
console.log('[Bilibili Loudness Equalizer] AudioContext initialized.');
}
}
// 处理视频元素
function processVideoElement(video) {
if (currentVideoElement === video) return; // 已经处理过该元素
// 如果之前有连接其他视频,先断开(虽然 Bilibili 通常是销毁旧的 video 标签)
if (sourceNode) {
try {
sourceNode.disconnect();
} catch (e) {
console.warn('[Bilibili Loudness Equalizer] Failed to disconnect old source:', e);
}
}
currentVideoElement = video;
console.log('[Bilibili Loudness Equalizer] New video element detected:', video);
initAudioContext();
// 等待视频元数据加载完成再处理音频
const setupAudio = () => {
try {
// 创建媒体源节点
sourceNode = audioCtx.createMediaElementSource(video);
// 根据当前状态连接
updateAudioGraph();
console.log('[Bilibili Loudness Equalizer] Audio graph connected successfully.');
} catch (err) {
// 有时如果 video 已经被其他节点连接过,再次 createMediaElementSource 会报错
console.error('[Bilibili Loudness Equalizer] Error connecting audio source:', err);
}
};
// 如果视频已经加载了元数据,直接设置
if (video.readyState >= 1) {
setupAudio();
} else {
// 否则等待 loadedmetadata 事件
video.addEventListener('loadedmetadata', setupAudio, { once: true });
}
// 监听播放事件以恢复 AudioContext (浏览器通常禁止自动播放音频上下文)
const resumeAudioContext = () => {
if (audioCtx.state === 'suspended') {
audioCtx.resume().then(() => {
console.log('[Bilibili Loudness Equalizer] AudioContext resumed.');
});
}
};
video.addEventListener('play', resumeAudioContext);
// 某些情况下切换全屏可能不会触发 play,但会触发 playing
video.addEventListener('playing', resumeAudioContext);
// 监听视频尺寸变化 (覆盖网页全屏、剧场模式等)
let resizeTimeout;
const resizeObserver = new ResizeObserver(() => {
// 防抖,避免频繁触发
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
console.log('[Bilibili Loudness Equalizer] Video resize detected.');
handleFullscreenChange();
}, 500);
});
resizeObserver.observe(video);
}
// 观察 DOM 变化,查找