치지직 오디오 컴프레서

치지직 방송 오디오에 다이나믹 컴프레서를 적용합니다

Този скрипт не може да бъде инсталиран директно. Това е библиотека за други скриптове и може да бъде използвана с мета-директива // @require https://update.greasyfork.org/scripts/583302/1854628/%EC%B9%98%EC%A7%80%EC%A7%81%20%EC%98%A4%EB%94%94%EC%98%A4%20%EC%BB%B4%ED%94%84%EB%A0%88%EC%84%9C.js

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

// ==UserScript==
// @name         치지직 오디오 컴프레서
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  치지직 방송 오디오에 다이나믹 컴프레서를 적용합니다
// @author       Claude
// @match        https://chzzk.naver.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  // ──────────────────────────────────────────────
  // 기본 설정값 (GM_getValue로 저장/불러오기)
  // ──────────────────────────────────────────────
  const DEFAULTS = {
    enabled: true,
    threshold: -24,
    knee: 30,
    ratio: 4,
    attack: 3,     // ms 단위로 UI 표시, 실제 적용 시 /1000
    release: 250,  // ms 단위로 UI 표시
    gain: 0,       // 출력 게인 (dB)
  };

  function loadSettings() {
    const saved = GM_getValue('compressor_settings', null);
    return saved ? Object.assign({}, DEFAULTS, JSON.parse(saved)) : Object.assign({}, DEFAULTS);
  }

  function saveSettings(s) {
    GM_setValue('compressor_settings', JSON.stringify(s));
  }

  let settings = loadSettings();

  // ──────────────────────────────────────────────
  // Web Audio
  // ──────────────────────────────────────────────
  let audioCtx = null;
  let compressorNode = null;
  let gainNode = null;
  let sourceNode = null;
  let connected = false;

  function applySettings() {
    if (!compressorNode || !gainNode) return;
    compressorNode.threshold.value = settings.threshold;
    compressorNode.knee.value = settings.knee;
    compressorNode.ratio.value = settings.ratio;
    compressorNode.attack.value = settings.attack / 1000;
    compressorNode.release.value = settings.release / 1000;
    gainNode.gain.value = Math.pow(10, settings.gain / 20);
  }

  function connectCompressor(video) {
    if (connected) return;
    try {
      audioCtx = new (window.AudioContext || window.webkitAudioContext)();
      sourceNode = audioCtx.createMediaElementSource(video);
      compressorNode = audioCtx.createDynamicsCompressor();
      gainNode = audioCtx.createGain();

      applySettings();

      if (settings.enabled) {
        sourceNode.connect(compressorNode);
        compressorNode.connect(gainNode);
      } else {
        sourceNode.connect(gainNode);
      }
      gainNode.connect(audioCtx.destination);

      connected = true;
      updateStatus('연결됨');
    } catch (e) {
      console.error('[치지직 컴프레서]', e);
    }
  }

  function reconnect() {
    if (!compressorNode || !gainNode || !sourceNode) return;
    sourceNode.disconnect();
    compressorNode.disconnect();
    gainNode.disconnect();

    if (settings.enabled) {
      sourceNode.connect(compressorNode);
      compressorNode.connect(gainNode);
    } else {
      sourceNode.connect(gainNode);
    }
    gainNode.connect(audioCtx.destination);
    applySettings();
  }

  function watchVideo() {
    const observer = new MutationObserver(() => {
      const video = document.querySelector('video');
      if (video && !connected) {
        video.addEventListener('play', () => {
          if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
          connectCompressor(video);
        }, { once: true });
        if (!video.paused) connectCompressor(video);
      }
    });
    observer.observe(document.body, { childList: true, subtree: true });

    const video = document.querySelector('video');
    if (video) {
      if (!video.paused) connectCompressor(video);
      else video.addEventListener('play', () => connectCompressor(video), { once: true });
    }
  }

  // ──────────────────────────────────────────────
  // UI
  // ──────────────────────────────────────────────
  const PANEL_ID = 'chzzk-compressor-panel';
  const BTN_ID = 'chzzk-compressor-btn';

  function updateStatus(msg) {
    const el = document.getElementById('comp-status');
    if (el) el.textContent = msg;
  }

  function createUI() {
    // 토글 버튼
    const btn = document.createElement('button');
    btn.id = BTN_ID;
    btn.title = '오디오 컴프레서 설정';
    btn.innerHTML = '🎚️';
    Object.assign(btn.style, {
      position: 'fixed',
      bottom: '80px',
      right: '20px',
      zIndex: '99999',
      width: '44px',
      height: '44px',
      borderRadius: '50%',
      background: '#1bcd6b',
      border: 'none',
      fontSize: '20px',
      cursor: 'pointer',
      boxShadow: '0 2px 8px rgba(0,0,0,0.35)',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      lineHeight: '1',
    });
    btn.addEventListener('click', togglePanel);
    document.body.appendChild(btn);

    // 패널
    const panel = document.createElement('div');
    panel.id = PANEL_ID;
    Object.assign(panel.style, {
      position: 'fixed',
      bottom: '134px',
      right: '20px',
      zIndex: '99999',
      width: '300px',
      background: '#1c1c1e',
      border: '1px solid #333',
      borderRadius: '12px',
      padding: '16px',
      fontFamily: 'Pretendard, -apple-system, sans-serif',
      color: '#f0f0f0',
      display: 'none',
      boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
    });

    panel.innerHTML = `
      <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
        <span style="font-size:14px;font-weight:600;color:#fff">🎚️ 오디오 컴프레서</span>
        <span id="comp-status" style="font-size:11px;color:#1bcd6b;background:#0d2e1a;padding:2px 8px;border-radius:20px">대기 중</span>
      </div>

      <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;padding-bottom:14px;border-bottom:1px solid #2a2a2a">
        <label style="font-size:13px;color:#ccc">컴프레서 활성화</label>
        <label class="comp-toggle" style="position:relative;display:inline-block;width:40px;height:22px;cursor:pointer">
          <input type="checkbox" id="comp-enabled" style="opacity:0;width:0;height:0" ${settings.enabled ? 'checked' : ''}>
          <span class="comp-slider" style="
            position:absolute;top:0;left:0;right:0;bottom:0;
            background:${settings.enabled ? '#1bcd6b' : '#444'};
            border-radius:22px;transition:background .2s;
          ">
            <span style="
              position:absolute;top:3px;left:${settings.enabled ? '21px' : '3px'};
              width:16px;height:16px;background:#fff;border-radius:50%;transition:left .2s;
            "></span>
          </span>
        </label>
      </div>

      ${makeSlider('threshold', '임계값 (Threshold)', settings.threshold, -60, 0, 1, 'dB')}
      ${makeSlider('knee', '니 (Knee)', settings.knee, 0, 40, 1, 'dB')}
      ${makeSlider('ratio', '비율 (Ratio)', settings.ratio, 1, 20, 1, ':1')}
      ${makeSlider('attack', '어택 (Attack)', settings.attack, 1, 200, 1, 'ms')}
      ${makeSlider('release', '릴리즈 (Release)', settings.release, 10, 500, 10, 'ms')}
      ${makeSlider('gain', '출력 게인', settings.gain, -12, 12, 1, 'dB')}

      <button id="comp-reset" style="
        margin-top:14px;width:100%;padding:8px;
        background:transparent;border:1px solid #444;
        color:#aaa;border-radius:8px;font-size:12px;cursor:pointer;
      ">기본값으로 초기화</button>
    `;

    document.body.appendChild(panel);

    // 이벤트 바인딩
    bindEvents(panel);
  }

  function makeSlider(key, label, value, min, max, step, unit) {
    return `
      <div style="margin-bottom:12px">
        <div style="display:flex;justify-content:space-between;margin-bottom:4px">
          <label style="font-size:12px;color:#aaa">${label}</label>
          <span id="val-${key}" style="font-size:12px;color:#1bcd6b;font-weight:600">${value}${unit}</span>
        </div>
        <input type="range" id="slider-${key}" min="${min}" max="${max}" step="${step}" value="${value}"
          style="width:100%;accent-color:#1bcd6b;height:4px;cursor:pointer">
      </div>
    `;
  }

  function bindEvents(panel) {
    const keys = ['threshold', 'knee', 'ratio', 'attack', 'release', 'gain'];
    const units = { threshold: 'dB', knee: 'dB', ratio: ':1', attack: 'ms', release: 'ms', gain: 'dB' };

    keys.forEach(key => {
      const slider = panel.querySelector(`#slider-${key}`);
      const valEl = panel.querySelector(`#val-${key}`);
      slider.addEventListener('input', () => {
        const v = Number(slider.value);
        valEl.textContent = v + units[key];
        settings[key] = v;
        saveSettings(settings);
        if (connected) applySettings();
      });
    });

    // 토글
    const enabledCb = panel.querySelector('#comp-enabled');
    enabledCb.addEventListener('change', () => {
      settings.enabled = enabledCb.checked;
      const sliderSpan = panel.querySelector('.comp-slider');
      const knob = sliderSpan.querySelector('span');
      sliderSpan.style.background = settings.enabled ? '#1bcd6b' : '#444';
      knob.style.left = settings.enabled ? '21px' : '3px';
      saveSettings(settings);
      if (connected) reconnect();
    });

    // 초기화
    panel.querySelector('#comp-reset').addEventListener('click', () => {
      settings = Object.assign({}, DEFAULTS);
      saveSettings(settings);
      keys.forEach(key => {
        panel.querySelector(`#slider-${key}`).value = settings[key];
        panel.querySelector(`#val-${key}`).textContent = settings[key] + units[key];
      });
      enabledCb.checked = settings.enabled;
      const sliderSpan = panel.querySelector('.comp-slider');
      const knob = sliderSpan.querySelector('span');
      sliderSpan.style.background = '#1bcd6b';
      knob.style.left = '21px';
      if (connected) { applySettings(); reconnect(); }
    });
  }

  function togglePanel() {
    const panel = document.getElementById(PANEL_ID);
    if (!panel) return;
    panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
    if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
  }

  // ──────────────────────────────────────────────
  // 초기화
  // ──────────────────────────────────────────────
  function init() {
    createUI();
    watchVideo();
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }

})();