cheukblog

又是美好的一日
日誌
發布於 2026年08月11日

YT Music sidechain

Youtube 看教學但又不想音樂直接蓋過人聲,所以用 Gemini 寫了個音樂 sidechain 的 script。

現在學 C++ 可以有背景音樂,爽。

Script -> 點我(失效)

更:

// ==UserScript==
// @name         Universal Audio Sidechain with UI (v6.3 Multi-Tab Stabilized)
// @namespace    http://tampermonkey.net/
// @version      6.3
// @description  Hijacks native audio pipelines with sidechain ducking. Multi-tab registry prevents volume pumping.
// @author       You
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addValueChangeListener
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  // Helper to safely parse localStorage values
  function getStoredFloat(key, defaultValue) {
      const val = localStorage.getItem(key);
      if (val === null) return defaultValue;
      const parsed = parseFloat(val);
      return isNaN(parsed) ? defaultValue : parsed;
  }

  // Generate unique ID for this browser tab instance
  const TAB_ID = 'tab_' + Math.random().toString(36).substring(2, 9);

  // =========================================================================
  // USER CONFIGURATION (DEFAULT SETTINGS)
  // =========================================================================
  const CONFIG = {
    ENABLED: true,
    DUCK_VOLUME: getStoredFloat('sc_duck_volume', 0.10),
    ATTACK_TIME: getStoredFloat('sc_attack_time', 0.05),
    RELEASE_TIME: getStoredFloat('sc_release_time', 0.60),

    CARRIER_DOMAINS: [
        'www.youtube.com',
        'twitch.tv',
        'bilibili.com'
    ],
    MODULATED_DOMAINS: [
        'music.youtube.com',
        'app.chillhop.com',
        'open.spotify.com',
        'soundcloud.com',
        'music.apple.com'
    ]
  };
  // =========================================================================

  const currentHost = window.location.hostname;
  const isCarrier = CONFIG.CARRIER_DOMAINS.some(domain => currentHost.includes(domain));
  const isModulated = CONFIG.MODULATED_DOMAINS.some(domain => currentHost.includes(domain));

  if (!isCarrier && !isModulated) return;

  // ==========================================
  // 1. CARRIER BEHAVIOR (Sender - Tab Registry System)
  // ==========================================
  if (isCarrier) {
    window.addEventListener('DOMContentLoaded', () => {
      let lastTriggerState = false;
      let lastWriteTime = 0;
      let triggerHoldTimer = 0;

      // Clean up tab registration on exit
      window.addEventListener('beforeunload', () => {
        const carriers = GM_getValue('sc_active_carriers') || {};
        delete carriers[TAB_ID];
        GM_setValue('sc_active_carriers', carriers);
      });

      setInterval(() => {
        const mediaElems = document.querySelectorAll('video, audio');
        let isPlaying = false;

        for (const media of mediaElems) {
          // Filter out tiny thumbnail preview elements (e.g. YouTube hover videos)
          const isMiniPreview = media.offsetWidth > 0 && media.offsetWidth < 180 && media.offsetHeight < 180;
          if (!isMiniPreview && !media.paused && !media.ended && media.readyState > 2) {
            isPlaying = true;
            break;
          }
        }

        const now = Date.now();
        if (isPlaying) {
          triggerHoldTimer = now;
        }

        // 800ms hold time prevents volume pumping during video buffering or seek transitions
        const isTriggered = isPlaying || (now - triggerHoldTimer < 800);

        if (isTriggered !== lastTriggerState || now - lastWriteTime > 400) {
          const carriers = GM_getValue('sc_active_carriers') || {};

          if (isTriggered) {
            carriers[TAB_ID] = now;
          } else {
            delete carriers[TAB_ID];
          }

          // Prune stale tabs (>3000ms inactive)
          for (const id in carriers) {
            if (now - carriers[id] > 3000) {
              delete carriers[id];
            }
          }

          GM_setValue('sc_active_carriers', carriers);
          lastTriggerState = isTriggered;
          lastWriteTime = now;
        }
      }, 50);
    });
  }

  // ==========================================
  // 2. MODULATED BEHAVIOR (Target Receiver + UI)
  // ==========================================
  if (isModulated) {

    const injectCode = `
      (function(DUCK_VOL, ATTACK, RELEASE, INITIAL_ENABLE) {
        let duckTarget = 1.0;
        let duckCurrent = 1.0;
        let configDuckVol = DUCK_VOL;
        let configAttack = ATTACK;
        let configRelease = RELEASE;
        let isEnabled = INITIAL_ENABLE;

        const htmlMedia = new Set();
        const webAudioGains = new Set();
        let activeAnalyser = null;
        const historyScope = new Array(200).fill(1.0);

        // [Engine 1]: HTMLMediaElement Hijack
        const origVolDesc = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'volume');
        if (origVolDesc) {
            Object.defineProperty(HTMLMediaElement.prototype, 'volume', {
                get() {
                    return this._userBaseVol !== undefined ? this._userBaseVol : origVolDesc.get.call(this);
                },
                set(v) {
                    this._userBaseVol = v;
                    origVolDesc.set.call(this, v * (isEnabled ? duckCurrent : 1.0));
                }
            });
            const origPlay = HTMLMediaElement.prototype.play;
            HTMLMediaElement.prototype.play = function() {
                htmlMedia.add(this);
                if (this._userBaseVol === undefined) this._userBaseVol = origVolDesc.get.call(this);
                origVolDesc.set.call(this, this._userBaseVol * (isEnabled ? duckCurrent : 1.0));
                return origPlay.apply(this, arguments);
            };
        }

        // [Engine 2]: Web Audio API Hijack
        const OriginalAudioContext = window.AudioContext || window.webkitAudioContext;
        if (OriginalAudioContext) {
            window.AudioContext = new Proxy(OriginalAudioContext, {
                construct(target, args) {
                    const ctx = new target(...args);
                    const masterGain = ctx.createGain();
                    const analyser = ctx.createAnalyser();
                    analyser.fftSize = 512;
                    activeAnalyser = analyser;

                    masterGain.gain.value = isEnabled ? duckCurrent : 1.0;
                    masterGain.connect(analyser);
                    analyser.connect(ctx.destination);
                    webAudioGains.add(masterGain);

                    Object.defineProperty(ctx, 'destination', { get: () => masterGain });
                    return ctx;
                }
            });
            window.webkitAudioContext = window.AudioContext;
        }

        // [DSP & Animation Loop]
        let lastTime = performance.now();
        function loop() {
            const time = performance.now();
            const dt = Math.min((time - lastTime) / 1000, 0.1);
            lastTime = time;

            const activeTarget = isEnabled ? duckTarget : 1.0;

            // Math precision snapping prevents floating-point volume jitter
            if (Math.abs(duckCurrent - activeTarget) > 0.001) {
                const step = activeTarget < duckCurrent
                    ? (1 / Math.max(0.01, configAttack)) * dt
                    : (1 / Math.max(0.01, configRelease)) * dt;

                if (activeTarget < duckCurrent) {
                    duckCurrent = Math.max(activeTarget, duckCurrent - step);
                } else {
                    duckCurrent = Math.min(activeTarget, duckCurrent + step);
                }

                applyGainToAll(duckCurrent);
            } else if (duckCurrent !== activeTarget) {
                duckCurrent = activeTarget;
                applyGainToAll(duckCurrent);
            }

            historyScope.push(duckCurrent);
            if (historyScope.length > 200) historyScope.shift();

            renderVisualizers();
        }

        function applyGainToAll(gainVal) {
            htmlMedia.forEach(m => {
                if(m._userBaseVol !== undefined) {
                    origVolDesc.set.call(m, m._userBaseVol * gainVal);
                }
            });

            webAudioGains.forEach(g => {
                if(g.context.state !== 'closed') g.gain.value = gainVal;
            });
        }

        function renderVisualizers() {
            const cAdsr = document.getElementById('sc-canvas-adsr');
            if (cAdsr) {
                const ctx = cAdsr.getContext('2d');
                const w = cAdsr.width;
                const h = cAdsr.height;
                ctx.clearRect(0, 0, w, h);

                ctx.strokeStyle = '#222';
                ctx.lineWidth = 1;
                ctx.beginPath();
                ctx.moveTo(0, h * 0.5); ctx.lineTo(w, h * 0.5);
                ctx.stroke();

                const pad = 8;
                const totalDur = configAttack + 0.3 + configRelease;
                const xAttack = pad + (configAttack / totalDur) * (w - pad * 2);
                const xSustain = xAttack + (0.3 / totalDur) * (w - pad * 2);
                const xRelease = w - pad;

                const yMax = pad;
                const yDuck = pad + (1 - configDuckVol) * (h - pad * 2);

                ctx.strokeStyle = '#00d2ff';
                ctx.lineWidth = 2;
                ctx.beginPath();
                ctx.moveTo(pad, yMax);
                ctx.lineTo(xAttack, yDuck);
                ctx.lineTo(xSustain, yDuck);
                ctx.lineTo(xRelease, yMax);
                ctx.stroke();

                let dotX = pad;
                let dotY = pad + (1 - duckCurrent) * (h - pad * 2);

                if (duckCurrent < 0.99) {
                    if (duckTarget < 1.0) {
                        dotX = pad + (1 - (duckCurrent - configDuckVol) / Math.max(0.001, 1 - configDuckVol)) * (xAttack - pad);
                    } else {
                        dotX = xSustain + ((duckCurrent - configDuckVol) / Math.max(0.001, 1 - configDuckVol)) * (xRelease - xSustain);
                    }
                }

                ctx.fillStyle = '#ff0055';
                ctx.beginPath();
                ctx.arc(Math.min(w - pad, Math.max(pad, dotX)), dotY, 4, 0, Math.PI * 2);
                ctx.fill();
            }

            const cScope = document.getElementById('sc-canvas-scope');
            if (cScope) {
                const ctx = cScope.getContext('2d');
                const w = cScope.width;
                const h = cScope.height;
                ctx.fillStyle = '#0a0a0a';
                ctx.fillRect(0, 0, w, h);

                ctx.lineWidth = 1.5;
                ctx.strokeStyle = '#00ff66';
                ctx.shadowColor = '#00ff66';
                ctx.shadowBlur = 4;
                ctx.beginPath();

                if (activeAnalyser && activeAnalyser.context.state === 'running') {
                    const bufferLength = activeAnalyser.frequencyBinCount;
                    const dataArray = new Uint8Array(bufferLength);
                    activeAnalyser.getByteTimeDomainData(dataArray);

                    const sliceWidth = w / bufferLength;
                    let x = 0;
                    for (let i = 0; i < bufferLength; i++) {
                        const v = dataArray[i] / 128.0;
                        const y = (v * h) / 2;
                        if (i === 0) ctx.moveTo(x, y);
                        else ctx.lineTo(x, y);
                        x += sliceWidth;
                    }
                } else {
                    const sliceWidth = w / historyScope.length;
                    let x = 0;
                    for (let i = 0; i < historyScope.length; i++) {
                        const y = h - (historyScope[i] * (h - 10) + 5);
                        if (i === 0) ctx.moveTo(x, y);
                        else ctx.lineTo(x, y);
                        x += sliceWidth;
                    }
                }
                ctx.stroke();
                ctx.shadowBlur = 0;
            }
        }

        setInterval(loop, 16);

        window.addEventListener('message', e => {
            if (e.source !== window || !e.data) return;
            if (e.data.type === 'SC_DUCK') duckTarget = configDuckVol;
            if (e.data.type === 'SC_RESTORE') duckTarget = 1.0;
            if (e.data.type === 'SC_CONFIG_UPDATE') {
                configDuckVol = e.data.duckVol;
                configAttack = e.data.attack;
                configRelease = e.data.release;
                isEnabled = e.data.enabled;
                if (duckTarget < 1.0) duckTarget = configDuckVol;
            }
        });
      })(${CONFIG.DUCK_VOLUME}, ${CONFIG.ATTACK_TIME}, ${CONFIG.RELEASE_TIME}, ${CONFIG.ENABLED});
    `;

    const script = document.createElement('script');
    script.textContent = injectCode;
    document.documentElement.appendChild(script);
    script.remove();

    // UI Control Panel
    function renderControlUI() {
      if (document.getElementById('sc-ui-panel')) return;

      const panel = document.createElement('div');
      panel.id = 'sc-ui-panel';
      panel.style.cssText = `
        position: fixed; bottom: 20px; left: 20px; z-index: 999999;
        background: rgba(18, 18, 18, 0.94); color: #fff;
        padding: 12px 16px; border-radius: 10px;
        border: 1px solid rgba(255, 255, 255, 0.15);
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
        font-size: 12px; width: 240px; box-shadow: 0 8px 24px rgba(0,0,0,0.5);
        backdrop-filter: blur(8px); pointer-events: auto; user-select: none;
      `;

      panel.innerHTML = `
        <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; border-bottom:1px solid rgba(255,255,255,0.1); padding-bottom:6px;">
          <span style="font-weight:700; color:#00d2ff; display:flex; align-items:center; gap:5px;">🎛️ Sidechain DSP</span>
          <span id="sc-toggle-btn" style="cursor:pointer; color:#888; font-size:11px;">[Collapse]</span>
        </div>
        <div id="sc-body">
          <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
            <label style="color:#ccc;">Enable Sidechain</label>
            <input type="checkbox" id="sc-chk-enable" ${CONFIG.ENABLED ? 'checked' : ''} style="cursor:pointer;">
          </div>

          <div style="margin-bottom:8px;">
            <div style="display:flex; justify-content:space-between; color:#bbb; margin-bottom:3px;">
              <span>Duck Volume:</span>
              <span id="sc-val-duck">${Math.round(CONFIG.DUCK_VOLUME * 100)}%</span>
            </div>
            <input type="range" id="sc-rng-duck" min="0" max="1" step="0.01" value="${CONFIG.DUCK_VOLUME}" style="width:100%; cursor:pointer; accent-color:#00d2ff;">
          </div>

          <div style="margin-bottom:8px;">
            <div style="display:flex; justify-content:space-between; color:#bbb; margin-bottom:3px;">
              <span>Attack:</span>
              <span id="sc-val-attack">${Math.round(CONFIG.ATTACK_TIME * 1000)}ms</span>
            </div>
            <input type="range" id="sc-rng-attack" min="0.01" max="1" step="0.01" value="${CONFIG.ATTACK_TIME}" style="width:100%; cursor:pointer; accent-color:#00d2ff;">
          </div>

          <div style="margin-bottom:8px;">
            <div style="display:flex; justify-content:space-between; color:#bbb; margin-bottom:3px;">
              <span>Release:</span>
              <span id="sc-val-release">${Math.round(CONFIG.RELEASE_TIME * 1000)}ms</span>
            </div>
            <input type="range" id="sc-rng-release" min="0.05" max="2" step="0.05" value="${CONFIG.RELEASE_TIME}" style="width:100%; cursor:pointer; accent-color:#00d2ff;">
          </div>

          <div style="margin-top:10px; margin-bottom:6px;">
            <div style="color:#aaa; font-size:10px; margin-bottom:2px;">Envelope ADSR Curve:</div>
            <canvas id="sc-canvas-adsr" width="240" height="45" style="background:#111; border-radius:4px; border:1px solid #333; display:block;"></canvas>
          </div>

          <div style="margin-top:6px; margin-bottom:6px;">
            <div style="color:#aaa; font-size:10px; margin-bottom:2px;">Oscilloscope:</div>
            <canvas id="sc-canvas-scope" width="240" height="45" style="background:#0a0a0a; border-radius:4px; border:1px solid #333; display:block;"></canvas>
          </div>

          <div id="sc-status" style="font-size:10px; text-align:right; color:#777; margin-top:6px; font-weight:600;">
            Status: Idle
          </div>
        </div>
      `;

      document.body.appendChild(panel);

      const chkEnable = document.getElementById('sc-chk-enable');
      const rngDuck = document.getElementById('sc-rng-duck');
      const rngAttack = document.getElementById('sc-rng-attack');
      const rngRelease = document.getElementById('sc-rng-release');
      const toggleBtn = document.getElementById('sc-toggle-btn');
      const scBody = document.getElementById('sc-body');

      let isCollapsed = false;
      toggleBtn.addEventListener('click', () => {
        isCollapsed = !isCollapsed;
        scBody.style.display = isCollapsed ? 'none' : 'block';
        toggleBtn.innerText = isCollapsed ? '[Expand]' : '[Collapse]';
      });

      const updateConfig = () => {
        CONFIG.ENABLED = chkEnable.checked;
        CONFIG.DUCK_VOLUME = parseFloat(rngDuck.value);
        CONFIG.ATTACK_TIME = parseFloat(rngAttack.value);
        CONFIG.RELEASE_TIME = parseFloat(rngRelease.value);

        localStorage.setItem('sc_duck_volume', CONFIG.DUCK_VOLUME);
        localStorage.setItem('sc_attack_time', CONFIG.ATTACK_TIME);
        localStorage.setItem('sc_release_time', CONFIG.RELEASE_TIME);

        document.getElementById('sc-val-duck').innerText = Math.round(CONFIG.DUCK_VOLUME * 100) + '%';
        document.getElementById('sc-val-attack').innerText = Math.round(CONFIG.ATTACK_TIME * 1000) + 'ms';
        document.getElementById('sc-val-release').innerText = Math.round(CONFIG.RELEASE_TIME * 1000) + 'ms';

        window.postMessage({
          type: 'SC_CONFIG_UPDATE',
          enabled: CONFIG.ENABLED,
          duckVol: CONFIG.DUCK_VOLUME,
          attack: CONFIG.ATTACK_TIME,
          release: CONFIG.RELEASE_TIME
        }, '*');
      };

      [chkEnable, rngDuck, rngAttack, rngRelease].forEach(el => el.addEventListener('input', updateConfig));
    }

    window.addEventListener('DOMContentLoaded', () => {
      renderControlUI();
      setInterval(() => {
        if (!document.getElementById('sc-ui-panel')) renderControlUI();
      }, 2000);
    });

    let localTriggerState = false;

    function checkActiveCarriers(carriers) {
      if (!carriers || typeof carriers !== 'object') return false;
      const now = Date.now();
      for (const id in carriers) {
        if (now - carriers[id] < 3000) {
          return true; // Active playing carrier tab exists
        }
      }
      return false;
    }

    function updateStatusUI(isTriggered) {
      const statusEl = document.getElementById('sc-status');
      if (statusEl) {
        if (!CONFIG.ENABLED) {
            statusEl.innerText = 'Status: Disabled';
            statusEl.style.color = '#777';
        } else if (isTriggered) {
            statusEl.innerText = 'Status: 🔊 Ducking Audio';
            statusEl.style.color = '#00d2ff';
        } else {
            statusEl.innerText = 'Status: 🎵 Normal Output';
            statusEl.style.color = '#888';
        }
      }
    }

    GM_addValueChangeListener('sc_active_carriers', (name, oldValue, newValue) => {
      const isTriggered = checkActiveCarriers(newValue);

      if (isTriggered !== localTriggerState) {
          window.postMessage({ type: isTriggered ? 'SC_DUCK' : 'SC_RESTORE' }, '*');
          localTriggerState = isTriggered;
          updateStatusUI(isTriggered);
      }
    });

    // Fallback registry heartbeat scan
    setInterval(() => {
      const carriers = GM_getValue('sc_active_carriers');
      const isTriggered = checkActiveCarriers(carriers);

      if (isTriggered !== localTriggerState) {
        window.postMessage({ type: isTriggered ? 'SC_DUCK' : 'SC_RESTORE' }, '*');
        localTriggerState = isTriggered;
        updateStatusUI(isTriggered);
      }
    }, 500);
  }
})();