치지직 오디오 컴프레서

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

ეს სკრიპტი არ უნდა იყოს პირდაპირ დაინსტალირებული. ეს ბიბლიოთეკაა, სხვა სკრიპტებისთვის უნდა ჩართეთ მეტა-დირექტივაში // @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.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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();
  }

})();