// ==UserScript== // @name YouTube audio compressor // @namespace https://eaguru.guru/ // @version 0.4.2 // @description Adds an audio compressor option to YouTube videos. Now with over-engineering! // @author Vivelin, notwa // @match https://*.youtube.com/* // @updateURL https://gist.github.com/notwa/9b8466b0c2ca48d756afcd02a5e43739/raw/YouTube%2520audio%2520compressor.user.js // @run-at document-idle // @grant none // ==/UserScript== (function () { 'use strict'; let active = false; let audioContext = null; let source = null; let compressor = null; let gainIn = null; let filterIn = null; let filterOut = null; let gainOut = null; let compressorMenuItem = null; // these filters very roughly approximate the inverse of ISO 226:2003 at 70 phons. // NOTE: these MUST have all zeros (and poles) within the unit circle, // else the output signal will grow indefinitely (i.e. blow up). // normally, the zeros of an IIR are not subject to this constraint. // however, as a quick 'n' dirty method to reverse the effect // of the filters after compression, i am using their inverses, // with their numerators and denominators swapped. // // zeroth-degree terms are fixed at 1.0. // 1.0 + num_1 * z^-1 + num_2 * z^-2 // H(z) = gain * ----------------------------------- // 1.0 + den_1 * z^-1 + den_2 * z^-2 // gain num_1 num_2 den_1 den_2 const iir44100 = [+0.4707103, -1.1384015, +0.1384129, -1.5989047, +0.6369472]; const iir48000 = [+0.4560796, -1.1713186, +0.1713303, -1.6291792, +0.6617519]; const iir88200 = [+0.3747475, -1.4029588, +0.4029728, -1.7895837, +0.8000784]; const iir96000 = [+0.3665513, -1.4342713, +0.4342856, -1.8057964, +0.8147329]; let fancy = true; const iirs = { 44100: iir44100, 48000: iir48000, 88200: iir88200, 96000: iir96000 } function createFilter(coeffs, flip, moreGain = 1.0) { const gain = flip ? moreGain / coeffs[0] : moreGain * coeffs[0]; const b0 = gain; const b1 = gain * coeffs[flip ? 3 : 1]; const b2 = gain * coeffs[flip ? 4 : 2]; const a0 = 1.0; const a1 = coeffs[flip ? 1 : 3]; const a2 = coeffs[flip ? 2 : 4]; const num = [b0, b1, b2]; const den = [a0, a1, a2]; const filter = audioContext.createIIRFilter(num, den); return filter; } function createCompressorMenuItem() { const menuItem = document.createElement('div'); menuItem.className = 'ytp-menuitem'; menuItem.setAttribute('role', 'menuitemcheckbox'); menuItem.setAttribute('aria-checked', 'false'); menuItem.tabIndex = 0; const icon = document.createElement('div'); icon.className = 'ytp-menuitem-icon'; menuItem.appendChild(icon); const label = document.createElement('div'); label.className = 'ytp-menuitem-label'; label.textContent = 'Compressor'; menuItem.appendChild(label); const content = document.createElement('div'); content.className = 'ytp-menuitem-content'; const toggleCheckbox = document.createElement('div'); toggleCheckbox.className = 'ytp-menuitem-toggle-checkbox'; content.appendChild(toggleCheckbox); menuItem.appendChild(content); console.log(menuItem); return menuItem; } function setupCompressor(player, ytpPanelMenu) { if (!audioContext) { audioContext = new AudioContext(); } const iir = iirs[audioContext.sampleRate]; if (typeof iir === "undefined") { fancy = false; } if (!source) { source = audioContext.createMediaElementSource(player); } if (!gainIn) { gainIn = audioContext.createGain(); gainIn.gain.setValueAtTime(0.7, audioContext.currentTime); } if (!filterIn && fancy) { filterIn = createFilter(iir, false, 2.0); } if (!compressor) { compressor = audioContext.createDynamicsCompressor(); compressor.threshold.setValueAtTime(-50, audioContext.currentTime); compressor.knee.setValueAtTime(40, audioContext.currentTime); compressor.ratio.setValueAtTime(12, audioContext.currentTime); compressor.attack.setValueAtTime(0.01, audioContext.currentTime); compressor.release.setValueAtTime(0.33, audioContext.currentTime); } if (!filterOut && fancy) { filterOut = createFilter(iir, true, 0.5); } if (!gainOut) { gainOut = audioContext.createGain(); gainOut.gain.setValueAtTime(0.7, audioContext.currentTime); } if (active) { source.connect(gainIn); } else { source.connect(audioContext.destination); } if (fancy) { gainIn.connect(filterIn); filterIn.connect(compressor); compressor.connect(filterOut); filterOut.connect(gainOut); } else { gainIn.connect(compressor); compressor.connect(gainOut); } if (!compressorMenuItem) { compressorMenuItem = createCompressorMenuItem(); ytpPanelMenu.appendChild(compressorMenuItem); compressorMenuItem.onclick = function () { const isActive = compressorMenuItem.getAttribute('aria-checked'); if (isActive === 'false') { compressorMenuItem.setAttribute('aria-checked', 'true'); source.disconnect(audioContext.destination); source.connect(gainIn); gainOut.connect(audioContext.destination); active = true; } else { compressorMenuItem.setAttribute('aria-checked', 'false'); source.disconnect(gainIn); gainOut.disconnect(audioContext.destination); source.connect(audioContext.destination); active = false; } }; } } function startSetup() { const player = document.querySelector('#ytd-player video'); const ytpPanelMenu = document.querySelector('#ytd-player .ytp-settings-menu .ytp-panel-menu'); if (player && ytpPanelMenu) { setupCompressor(player, ytpPanelMenu); console.info('Compressor option added'); return true; } return false; } function trySetup() { console.debug('Polling for player...'); const intervalId = window.setInterval(function () { if (startSetup()) { window.clearInterval(intervalId); } }, 100); const cancelId = window.setTimeout(function () { window.clearInterval(intervalId); }, 10000); } if (!startSetup()) { trySetup(); window.addEventListener('yt-navigate-finish', function () { trySetup(); }); } })();