치지직 방송 오디오에 다이나믹 컴프레서를 적용합니다
Este script não deve ser instalado diretamente. Este script é uma biblioteca de outros scripts para incluir com o diretório meta // @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
// ==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();
}
})();