// ==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 变化,查找