치지직 방송 오디오에 다이나믹 컴프레서를 적용합니다
Script này sẽ không được không được cài đặt trực tiếp. Nó là một thư viện cho các script khác để bao gồm các chỉ thị 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();
}
})();