音量控制 -- Video Volume Booster with Advanced Audio Enhancer

按喜好调整网站音量,按域名存储(即不同网站能分别设置)。音量增大降低(0.1x-5.0x) + EQ降噪 + 动态压缩器、自动检测音质以限制最高音量,支持开关项勾选控制。高音质网站默认关闭。

// ==UserScript==
// @name         音量控制 -- Video Volume Booster with Advanced Audio Enhancer
// @namespace    https://greasyfork.org/users/1171320
// @version      1.0
// @description  按喜好调整网站音量,按域名存储(即不同网站能分别设置)。音量增大降低(0.1x-5.0x) + EQ降噪 + 动态压缩器、自动检测音质以限制最高音量,支持开关项勾选控制。高音质网站默认关闭。
// @author         yzcjd
// @author2       ChatGPT4 辅助
// @match        *://*/*
// @grant        none
// @license MIT
// ==/UserScript==

(function () {
  'use strict';

  const GOOD_SITES = [
    'netflix.com', 'imdb.com', 'disneyplus.com', 'hulu.com', 'primevideo.com',
    'apple.com', 'hbomax.com', 'paramountplus.com', 'peacocktv.com', 'qobuz.com',
    'tidal.com', 'music.apple.com', 'music.amazon.com', 'deezer.com', 'soundcloud.com'
  ];
  const domain = location.hostname;
  const isGoodSite = GOOD_SITES.some(site => domain.includes(site));

  // 读取域名相关设置
  const getStoredSetting = (key) => {
    return JSON.parse(localStorage.getItem(`${key}_${domain}`)) ?? false;
  };

  // 存储设置
  const setStoredSetting = (key, value) => {
    localStorage.setItem(`${key}_${domain}`, JSON.stringify(value));
  };

  const settings = {
    eqEnabled: !isGoodSite && getStoredSetting('eqEnabled'),
    compressorEnabled: !isGoodSite && getStoredSetting('compressorEnabled'),
    autoLimitEnabled: !isGoodSite && getStoredSetting('autoLimitEnabled')
  };

  let audioContext, analyser, audioDataArray;
  let gainNodes = new Map(), eqNodes = new Map(), compressorNodes = new Map();
  let maxAllowedGain = 5.0;
  const savedVolume = parseFloat(localStorage.getItem(`volume_multiplier_${domain}`)) || 1.0;

  const style = document.createElement('style');
  style.textContent = `
    #volume-control-button {
      position: fixed;
      top: 60px;
      right: 5px;
      z-index: 99999;
      background: #f5f5f5;
      color: #000;
      padding: 4px 8px;
      border-radius: 6px;
      border: 1px solid #ccc;
      cursor: pointer;
      font-size: 12px;
      transform: scale(0.75);
    }
    #volume-control-panel {
      position: fixed;
      top: 100px;
      right: 5px;
      z-index: 99999;
      background: #fff;
      border: 1px solid #ccc;
      border-radius: 6px;
      padding: 10px;
      display: none;
      box-shadow: 0 2px 8px rgba(0,0,0,0.1);
      font-size: 12px;
    }
    #volume-slider {
      width: 120px;
    }
    #volume-message {
      font-size: 12px;
      color: #555;
      margin-top: 6px;
    }
    #volume-options label {
      display: block;
      margin: 4px 0;
    }
  `;
  document.head.appendChild(style);

  const button = document.createElement('button');
  button.id = 'volume-control-button';
  button.textContent = 'volume';
  document.body.appendChild(button);

  const panel = document.createElement('div');
  panel.id = 'volume-control-panel';
  panel.innerHTML = `
    <label for="volume-slider">音量倍率:</label>
    <input type="range" id="volume-slider" min="0.1" max="5" step="0.1">
    <input type="number" id="volume-input" min="0.1" max="5" step="0.1" style="width:50px;">
    <div id="volume-options">
      <label><input type="checkbox" id="eq-toggle"> EQ降噪</label>
      <label><input type="checkbox" id="compressor-toggle"> 动态压缩器</label>
      <label><input type="checkbox" id="limit-toggle"> 限制最高音量</label>
    </div>
    <div id="volume-message"></div>
  `;
  document.body.appendChild(panel);

  const slider = document.getElementById('volume-slider');
  const input = document.getElementById('volume-input');
  const message = document.getElementById('volume-message');

  const eqToggle = document.getElementById('eq-toggle');
  const compressorToggle = document.getElementById('compressor-toggle');
  const limitToggle = document.getElementById('limit-toggle');

  // 初始化设置
  slider.value = input.value = savedVolume;
  eqToggle.checked = settings.eqEnabled;
  compressorToggle.checked = settings.compressorEnabled;
  limitToggle.checked = settings.autoLimitEnabled;

  function getAudioContext() {
    if (!audioContext) {
      audioContext = new (window.AudioContext || window.webkitAudioContext)();
      analyser = audioContext.createAnalyser();
      analyser.fftSize = 256;
      audioDataArray = new Uint8Array(analyser.frequencyBinCount);
    }
    return audioContext;
  }

  function applyGain(volume) {
    const videos = document.querySelectorAll('video');
    videos.forEach(video => {
      if (!video.src) return;

      if (!gainNodes.has(video)) {
        const ctx = getAudioContext();
        const source = ctx.createMediaElementSource(video);
        const gainNode = ctx.createGain();
        let node = source;

        if (settings.eqEnabled) {
          const eq = ctx.createBiquadFilter();
          eq.type = 'highshelf';
          eq.frequency.value = 4000;
          eq.gain.value = -15;
          node.connect(eq);
          node = eq;
          eqNodes.set(video, eq);
        }

        if (settings.compressorEnabled) {
          const comp = ctx.createDynamicsCompressor();
          comp.threshold.value = -50;
          comp.knee.value = 40;
          comp.ratio.value = 12;
          comp.attack.value = 0.003;
          comp.release.value = 0.25;
          node.connect(comp);
          node = comp;
          compressorNodes.set(video, comp);
        }

        node.connect(gainNode);
        gainNode.connect(ctx.destination);

        gainNodes.set(video, gainNode);
      }

      const gainNode = gainNodes.get(video);
      gainNode.gain.value = Math.min(volume, maxAllowedGain);
    });
  }

  function analyzeAudio() {
    if (!settings.autoLimitEnabled) {
      maxAllowedGain = 5.0;
      return;
    }

    analyser.getByteTimeDomainData(audioDataArray);
    let sumSquares = 0, max = 0, min = 255;
    for (let i = 0; i < audioDataArray.length; i++) {
      const val = audioDataArray[i];
      sumSquares += (val - 128) ** 2;
      if (val > max) max = val;
      if (val < min) min = val;
    }
    const rms = Math.sqrt(sumSquares / audioDataArray.length) / 128;
    const dynamicRange = (max - min) / 255;

    if (rms > 0.5 && dynamicRange < 0.2) {
      maxAllowedGain = 2.5;
      message.textContent = '检测到音源音质较差,仅允许放大至 2.5x';
    } else if (rms > 0.3 && dynamicRange < 0.3) {
      maxAllowedGain = 3.5;
      message.textContent = '动态范围较低,建议不超过 3.5x';
    } else {
      maxAllowedGain = 5.0;
      message.textContent = '音质良好,允许放大至 5.0x';
    }

    slider.max = input.max = maxAllowedGain;
    if (parseFloat(slider.value) > maxAllowedGain) {
      slider.value = input.value = maxAllowedGain;
      applyGain(maxAllowedGain);
    }
  }

  setInterval(analyzeAudio, 2000);

  function setVolume(val) {
    const v = Math.max(0.1, Math.min(maxAllowedGain, parseFloat(val) || 1.0));
    slider.value = input.value = v;
    localStorage.setItem(`volume_multiplier_${domain}`, v);
    applyGain(v);
  }

  slider.addEventListener('input', () => setVolume(slider.value));
  input.addEventListener('input', () => setVolume(input.value));

  // 切换开关状态
  eqToggle.addEventListener('change', () => {
    settings.eqEnabled = eqToggle.checked;
    setStoredSetting('eqEnabled', eqToggle.checked);
    clearAudioNodes();
    setVolume(slider.value);
  });
  compressorToggle.addEventListener('change', () => {
    settings.compressorEnabled = compressorToggle.checked;
    setStoredSetting('compressorEnabled', compressorToggle.checked);
    clearAudioNodes();
    setVolume(slider.value);
  });
  limitToggle.addEventListener('change', () => {
    settings.autoLimitEnabled = limitToggle.checked;
    setStoredSetting('autoLimitEnabled', limitToggle.checked);
    analyzeAudio();
  });

  button.addEventListener('click', () => {
    panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
  });

  function clearAudioNodes() {
    gainNodes.clear();
    compressorNodes.clear();
    eqNodes.clear();
  }

  setVolume(savedVolume);
})();