// ==UserScript== // @name YouTube audio compressor // @namespace https://eaguru.guru/ // @version 0.5.2 // @description Adds an audio compressor option to YouTube videos. Now with over-engineering! Based on code by Vivelin and Wareya. // @author 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== (() => { "use strict"; // Boolean: let boosterActive = false; let compressorActive = false; let fancyCompression = true; // AudioContext: let context = null; // AudioNode: let source = null; let gainIn = null; let filterIn = null; let compressor = null; let filterOut = null; let gainOut = null; let limiter = null; // DOM: let boosterMenuItem = 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, only the poles of an IIR are 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, // wherein the numerators and denominators are swapped. // TODO: reduce ringing. // // 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]; 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 = context.createIIRFilter(num, den); return filter; } function createMenuItem(title, defaultState) { 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 = title; menuItem.appendChild(label); const toggleCheckbox = document.createElement("div"); toggleCheckbox.className = "ytp-menuitem-toggle-checkbox"; const content = document.createElement("div"); content.className = "ytp-menuitem-content"; content.appendChild(toggleCheckbox); menuItem.appendChild(content); if (defaultState && defaultState !== "false") { menuItem.setAttribute("aria-checked", "true"); } else { menuItem.setAttribute("aria-checked", "false"); } console.log(menuItem); return menuItem; } function ariaToggle(element) { const oldState = element.getAttribute("aria-checked"); const newState = oldState === "false" ? true : false; element.setAttribute("aria-checked", newState.toString()); return newState; } function reconnect() { if (source) source.disconnect(); if (gainIn) gainIn.disconnect(); if (filterIn) filterIn.disconnect(); if (compressor) compressor.disconnect(); if (filterOut) filterOut.disconnect(); if (gainOut) gainOut.disconnect(); if (limiter) limiter.disconnect(); if (!source) return false; let sequence = [source]; if (boosterActive || compressorActive) sequence.push(gainIn); if (compressorActive) { if (fancyCompression) sequence.push(filterIn); sequence.push(compressor); if (fancyCompression) sequence.push(filterOut); } if (boosterActive || compressorActive) sequence.push(gainOut); if (boosterActive) sequence.push(limiter); // hook it all up: sequence = sequence.filter((item) => item); // remove falsy (null) elements for (let i = 1; i < sequence.length; i++) { sequence[i - 1].connect(sequence[i]); } sequence.at(-1).connect(context.destination); let compDrive = 1.0; let limitDrive = 1.0; if (boosterActive && limiter) { compDrive *= 3.1622776601683795; // +10 dB if (compressorActive) { compDrive *= 0.7943282347242815; // -2 dB limitDrive *= 1.4125375446227544; // +3 dB } } else if (compressorActive) { compDrive *= 0.7079457843841379; // -3 dB limitDrive *= 0.7079457843841379; // -3 dB } gainIn.gain.setValueAtTime(compDrive, context.currentTime); gainOut.gain.setValueAtTime(limitDrive, context.currentTime); return true; } function setupCompressor(player, ytpPanelMenu) { if (!context) { context = new AudioContext(); // load and register the limiter code: const uri = "data:text/javascript;base64,Ly8gcG9ydCBvZiBodHRwczovL2dpdGh1Yi5jb20vd2FyZXlhL0xpbWl0ZXJUZXN0L2Jsb2IvbWFpbi9NYWluLmdkCgpjbGFzcyBMaW1pdGVyIGV4dGVuZHMgQXVkaW9Xb3JrbGV0UHJvY2Vzc29yIHsKICBjb25zdHJ1Y3RvcihvcHRpb25zKSB7CiAgICBzdXBlcigpOwoKICAgIHRoaXMuc3JhdGUgPSBzYW1wbGVSYXRlOwoKICAgIHRoaXMucHJlX2dhaW4gPSAxLjA7CiAgICB0aGlzLnBvc3RfZ2FpbiA9IDEuMDsKICAgIHRoaXMubGltaXQgPSAwLjg5MTI1MDkzODEzMzc0NTY7IC8vIC0xIGRlY2liZWxzCgogICAgaWYgKGZhbHNlKSB7CiAgICAgIHRoaXMuYXR0YWNrICA9IDAuMDAxMDsKICAgICAgdGhpcy5zdXN0YWluID0gMC4wNDAwOwogICAgICB0aGlzLnJlbGVhc2UgPSAwLjA0MDA7CiAgICB9IGVsc2UgeyAvLyB1bHRyYWZhc3QKICAgICAgdGhpcy5hdHRhY2sgID0gMC4wMDA2OwogICAgICB0aGlzLnN1c3RhaW4gPSAwLjAwNjA7CiAgICAgIHRoaXMucmVsZWFzZSA9IDAuMDMwMDsKICAgIH0KCiAgICB0aGlzLnJlbGVhc2VfbWVtb3J5ID0gMC4wOwoKICAgIHRoaXMuYm94X2JsdXIgPSAwOwogICAgLy8gdGhpcy5hbXAgPSAxLjA7IC8vIG5vdCBzdGF0ZWZ1bAogICAgdGhpcy5yZWZfYW1wID0gMS4wOwoKICAgIHRoaXMubWVtb3J5X2N1cnNvciA9IDA7CiAgICB0aGlzLnN1c3RhaW5lZF9hbXAgPSBudWxsOwogICAgdGhpcy5zYW1wbGVfbWVtb3J5ID0gbnVsbDsKCiAgICB0aGlzLmFtcF9tZW1vcnlfY3Vyc29yID0gMDsKICAgIHRoaXMuYW1wX21lbW9yeSA9IG51bGw7CiAgICB0aGlzLmFtcF9taW5fYnVja2V0cyA9IG51bGw7CiAgICB0aGlzLmFtcF9idWNrZXRfc2l6ZSA9IDA7CgogICAgdGhpcy5wZWFrcyA9IG51bGw7CiAgICB0aGlzLmRlbGF5cyA9IFtdOwoKICAgIGNvbnN0IHRvU2FtcGxlcyA9ICh0KSA9PiBNYXRoLm1heChNYXRoLnJvdW5kKHRoaXMuc3JhdGUgKiB0KSwgMSk7CgogICAgbGV0IG1lbW9yeV9jb3VudCA9IHRvU2FtcGxlcyh0aGlzLmF0dGFjayk7CiAgICB0aGlzLnNhbXBsZV9tZW1vcnkgPSBuZXcgRmxvYXQzMkFycmF5KG1lbW9yeV9jb3VudCk7CiAgICB0aGlzLnN1c3RhaW5lZF9hbXAgPSBuZXcgRmxvYXQzMkFycmF5KG1lbW9yeV9jb3VudCk7CgogICAgbGV0IGFtcF9jb3VudCA9IHRvU2FtcGxlcyh0aGlzLmF0dGFjayArIHRoaXMuc3VzdGFpbik7CiAgICB0aGlzLmFtcF9idWNrZXRfc2l6ZSA9IE1hdGguc3FydChhbXBfY291bnQpIHwgMDsKICAgIHRoaXMuYW1wX21lbW9yeSA9IG5ldyBGbG9hdDMyQXJyYXkoYW1wX2NvdW50KTsKICAgIHRoaXMuYW1wX21lbW9yeS5maWxsKDEuMCk7CgogICAgbGV0IGFtcF9idWNrZXRfY291bnQgPSBhbXBfY291bnQgLyB0aGlzLmFtcF9idWNrZXRfc2l6ZSB8IDA7CiAgICB0aGlzLmFtcF9taW5fYnVja2V0cyA9IG5ldyBGbG9hdDMyQXJyYXkoYW1wX2J1Y2tldF9jb3VudCk7CiAgICB0aGlzLmFtcF9taW5fYnVja2V0cy5maWxsKDEuMCk7CgogICAgdGhpcy5wZWFrcyA9IG5ldyBGbG9hdDMyQXJyYXkoMTI4KTsgLy8gZ3Vlc3MgdGhlIGJ1ZmZlciBzaXplCiAgICB0aGlzLmRlbGF5cyA9IFt0aGlzLnNhbXBsZV9tZW1vcnldOwogIH0KCiAgcHJvY2VzcyhpbnB1dHMsIG91dHB1dHMsIHBhcmFtZXRlcnMpIHsKICAgIGNvbnN0IG1lbW9yeV9jb3VudCA9IHRoaXMuc2FtcGxlX21lbW9yeS5sZW5ndGg7IC8vIHRoaXMgc2hvd3MgdXAgYSBsb3QKCiAgICBjb25zdCBpbnB1dCA9IGlucHV0c1swXTsKICAgIGNvbnN0IG91dHB1dCA9IG91dHB1dHNbMF07CgogICAgaWYgKGlucHV0Lmxlbmd0aCA9PT0gMCkgewogICAgICByZXR1cm4gZmFsc2U7CiAgICB9CgogICAgd2hpbGUgKHRoaXMuZGVsYXlzLmxlbmd0aCA8IGlucHV0Lmxlbmd0aCkgewogICAgICB0aGlzLmRlbGF5cy5wdXNoKG5ldyBGbG9hdDMyQXJyYXkobWVtb3J5X2NvdW50KSk7CiAgICB9CgogICAgY29uc3QgYmxvY2tTaXplID0gaW5wdXRbMF0ubGVuZ3RoOwogICAgaWYgKGJsb2NrU2l6ZSA+IDAgJiYgdGhpcy5wZWFrcy5sZW5ndGggIT09IGJsb2NrU2l6ZSkgewogICAgICB0aGlzLnBlYWtzID0gbmV3IEZsb2F0MzJBcnJheShibG9ja1NpemUpOwogICAgfQogICAgdGhpcy5wZWFrcy5maWxsKDAuMCk7CgogICAgZm9yIChsZXQgYyA9IDA7IGMgPCBpbnB1dC5sZW5ndGg7IGMrKykgewogICAgICBjb25zdCBzb3VyY2UgPSBpbnB1dFtjXTsKICAgICAgZm9yIChsZXQgaSA9IDA7IGkgPCBibG9ja1NpemU7IGkrKykgewogICAgICAgIHRoaXMucGVha3NbaV0gPSBNYXRoLm1heCh0aGlzLnBlYWtzW2ldLCBNYXRoLmFicyhzb3VyY2VbaV0pKTsKICAgICAgfQogICAgfQoKICAgIGNvbnN0IGRlbGF5X2N1cnNvciA9IHRoaXMubWVtb3J5X2N1cnNvcjsKCiAgICBmb3IgKGxldCBpID0gMDsgaSA8IGJsb2NrU2l6ZTsgaSsrKSB7CiAgICAgIGNvbnN0IHNhbXBsZSA9IHRoaXMucGVha3NbaV0gKiB0aGlzLnByZV9nYWluOwoKICAgICAgLy8gZG8gdGhlIHJlbGVhc2UgY3VydmUKICAgICAgdGhpcy5yZWxlYXNlX21lbW9yeSA9IE1hdGgubWF4KDAuMCwgdGhpcy5yZWxlYXNlX21lbW9yeSAtIDEuMCAvIHRoaXMuc3JhdGUpOwogICAgICBsZXQgYW1wID0gMS4wOwogICAgICBpZiAodGhpcy5yZWxlYXNlID4gMC4wKSB7CiAgICAgICAgbGV0IHQgPSAxLjAgLSB0aGlzLnJlbGVhc2VfbWVtb3J5IC8gdGhpcy5yZWxlYXNlOwogICAgICAgIGFtcCA9ICgxLjAgLSB0KSAqIHRoaXMucmVmX2FtcCArIHQ7CiAgICAgIH0KCiAgICAgIC8vIHJlc2V0IHN1c3RhaW4gaWYgd2UgZXhjZWVkIHRoZSBsaW1pdAogICAgICBsZXQgcmVmX3ZhbCA9IE1hdGguYWJzKGFtcCAqIHNhbXBsZSk7CiAgICAgIGlmIChyZWZfdmFsID49IHRoaXMubGltaXQpIHsKICAgICAgICBsZXQgYW1vdW50ID0gdGhpcy5saW1pdCAvIHJlZl92YWw7CiAgICAgICAgYW1wICo9IGFtb3VudDsKICAgICAgICByZWZfdmFsICo9IGFtb3VudDsKICAgICAgICB0aGlzLnJlZl9hbXAgPSBhbXA7CiAgICAgICAgdGhpcy5yZWxlYXNlX21lbW9yeSA9IHRoaXMucmVsZWFzZTsKICAgICAgfQoKICAgICAgdGhpcy5hbXBfbWVtb3J5W3RoaXMuYW1wX21lbW9yeV9jdXJzb3JdID0gYW1wOwoKICAgICAgLy8gdXBkYXRlIGFmZmVjdGVkIGJ1Y2tldAogICAgICBsZXQgYnVja2V0ID0gdGhpcy5hbXBfbWVtb3J5X2N1cnNvciAvIHRoaXMuYW1wX2J1Y2tldF9zaXplIHwgMDsKICAgICAgdGhpcy5hbXBfbWluX2J1Y2tldHNbYnVja2V0XSA9IDEuMDsKICAgICAgbGV0IGVuZCA9IE1hdGgubWluKChidWNrZXQgKyAxKSAqIHRoaXMuYW1wX2J1Y2tldF9zaXplLCB0aGlzLmFtcF9tZW1vcnkubGVuZ3RoKTsKICAgICAgZm9yIChsZXQgaiA9IGJ1Y2tldCAqIHRoaXMuYW1wX2J1Y2tldF9zaXplOyBqIDwgZW5kOyBqKyspIHsKICAgICAgICB0aGlzLmFtcF9taW5fYnVja2V0c1tidWNrZXRdID0gTWF0aC5taW4odGhpcy5hbXBfbWluX2J1Y2tldHNbYnVja2V0XSwgdGhpcy5hbXBfbWVtb3J5W2pdKTsKICAgICAgfQoKICAgICAgLy8gbm93IGdldCB0aGUgY3VycmVudCBzdXN0YWluIHZhbHVlCiAgICAgIGxldCBtaW5fYW1wID0gMS4wOwogICAgICBmb3IgKGxldCBqID0gMDsgaiA8IHRoaXMuYW1wX21pbl9idWNrZXRzLmxlbmd0aDsgaisrKSB7CiAgICAgICAgbGV0IHBhc3RfYW1wID0gdGhpcy5hbXBfbWluX2J1Y2tldHNbal07CiAgICAgICAgbWluX2FtcCA9IE1hdGgubWluKG1pbl9hbXAsIHBhc3RfYW1wKTsKICAgICAgfQoKICAgICAgdGhpcy5hbXBfbWVtb3J5X2N1cnNvciArPSAxOwogICAgICB0aGlzLmFtcF9tZW1vcnlfY3Vyc29yICU9IHRoaXMuYW1wX21lbW9yeS5sZW5ndGg7CgogICAgICAvLyB1cGRhdGUgdGhlIHN1c3RhaW4gYnVmZmVyCiAgICAgIHRoaXMuYm94X2JsdXIgLT0gdGhpcy5zdXN0YWluZWRfYW1wW3RoaXMubWVtb3J5X2N1cnNvcl0gKiAzMjc2NyB8IDA7CiAgICAgIHRoaXMuc3VzdGFpbmVkX2FtcFt0aGlzLm1lbW9yeV9jdXJzb3JdID0gbWluX2FtcDsKICAgICAgdGhpcy5ib3hfYmx1ciArPSB0aGlzLnN1c3RhaW5lZF9hbXBbdGhpcy5tZW1vcnlfY3Vyc29yXSAqIDMyNzY3IHwgMDsKCiAgICAgIC8vIHVwZGF0ZSB0aGUgc2FtcGxlIG1lbW9yeSBidWZmZXIKICAgICAgLy8gTk9URShub3R3YSk6IHdlIGluc3RlYWQgZG8gdGhpcyBsYXRlciBmb3IgZWFjaCBjaGFubmVsLgogICAgICAvL2xldCByZXRfc2FtcGxlID0gdGhpcy5zdXN0YWluICsgdGhpcy5hdHRhY2sgPiAwLjAgPyB0aGlzLnNhbXBsZV9tZW1vcnlbdGhpcy5tZW1vcnlfY3Vyc29yXSA6IHNhbXBsZTsKICAgICAgLy90aGlzLnNhbXBsZV9tZW1vcnlbdGhpcy5tZW1vcnlfY3Vyc29yXSA9IHNhbXBsZTsKCiAgICAgIHRoaXMubWVtb3J5X2N1cnNvciArPSAxOwogICAgICB0aGlzLm1lbW9yeV9jdXJzb3IgJT0gbWVtb3J5X2NvdW50OwoKICAgICAgdGhpcy5wZWFrc1tpXSA9IHRoaXMuYm94X2JsdXIgLyAzMjc2NyAvIG1lbW9yeV9jb3VudDsKICAgIH0KCiAgICBjb25zdCBmaW5hbF9nYWluID0gdGhpcy5wcmVfZ2FpbiAqIHRoaXMucG9zdF9nYWluOwogICAgZm9yIChsZXQgYyA9IDA7IGMgPCBpbnB1dC5sZW5ndGg7IGMrKykgewogICAgICBjb25zdCBzb3VyY2UgPSBpbnB1dFtjXTsKICAgICAgY29uc3QgdGFyZ2V0ID0gb3V0cHV0W2NdOwogICAgICBpZiAodGhpcy5zdXN0YWluICsgdGhpcy5hdHRhY2sgPD0gMC4wKSB7CiAgICAgICAgZm9yIChsZXQgaSA9IDA7IGkgPCBibG9ja1NpemU7IGkrKykgewogICAgICAgICAgLy8gcmV0dXJuIHRoZSBsaW1pdGVkIHNhbXBsZQogICAgICAgICAgdGFyZ2V0W2ldID0gc291cmNlW2ldICogdGhpcy5wZWFrc1tpXSAqIGZpbmFsX2dhaW47CiAgICAgICAgfQogICAgICB9IGVsc2UgewogICAgICAgIGNvbnN0IGRlbGF5ID0gdGhpcy5kZWxheXNbY107CiAgICAgICAgbGV0IGxvY2FsX2N1cnNvciA9IGRlbGF5X2N1cnNvcjsKICAgICAgICBmb3IgKGxldCBpID0gMDsgaSA8IGJsb2NrU2l6ZTsgaSsrKSB7CiAgICAgICAgICBjb25zdCByZXRfc2FtcGxlID0gZGVsYXlbbG9jYWxfY3Vyc29yXTsKICAgICAgICAgIGRlbGF5W2xvY2FsX2N1cnNvcl0gPSBzb3VyY2VbaV07CgogICAgICAgICAgbG9jYWxfY3Vyc29yICs9IDE7CiAgICAgICAgICBsb2NhbF9jdXJzb3IgJT0gbWVtb3J5X2NvdW50OwoKICAgICAgICAgIC8vIHJldHVybiB0aGUgbGltaXRlZCBzYW1wbGUKICAgICAgICAgIHRhcmdldFtpXSA9IHJldF9zYW1wbGUgKiB0aGlzLnBlYWtzW2ldICogZmluYWxfZ2FpbjsKICAgICAgICB9CiAgICAgIH0KICAgIH0KCiAgICByZXR1cm4gZmFsc2U7IC8vIGRvY3Mgc2F5IHRvIHJldHVybiBmYWxzZSBmb3IgcHJvY2Vzc29ycyB3aXRoIG5vIHJldmVyYiB0YWlscwogIH0KfQoKcmVnaXN0ZXJQcm9jZXNzb3IoImxpbWl0ZXIiLCBMaW1pdGVyKTsK"; context.audioWorklet.addModule(uri).then(() => { limiter = new AudioWorkletNode(context, "limiter"); console.info("Limiter successfully initialized."); reconnect(); }); } const iir = iirs[context.sampleRate]; if (typeof iir === "undefined") { fancyCompression = false; } const now = context.currentTime; if (!source) { source = context.createMediaElementSource(player); } if (!gainIn) { gainIn = context.createGain(); gainIn.gain.setValueAtTime(1.0, now); } if (!filterIn && fancyCompression) { filterIn = createFilter(iir, false, 2.0); } if (!compressor) { compressor = context.createDynamicsCompressor(); compressor.threshold.setValueAtTime(-50, now); compressor.knee.setValueAtTime(40, now); compressor.ratio.setValueAtTime(12, now); compressor.attack.setValueAtTime(0.01, now); compressor.release.setValueAtTime(0.33, now); } if (!filterOut && fancyCompression) { filterOut = createFilter(iir, true, 0.5); } if (!gainOut) { gainOut = context.createGain(); gainOut.gain.setValueAtTime(1.0, now); } if (!boosterMenuItem) { boosterMenuItem = createMenuItem("Level Boost", boosterActive); boosterMenuItem.onclick = () => { boosterActive = ariaToggle(boosterMenuItem); reconnect(); }; ytpPanelMenu.appendChild(boosterMenuItem); } if (!compressorMenuItem) { compressorMenuItem = createMenuItem("Compressor", compressorActive); compressorMenuItem.onclick = () => { compressorActive = ariaToggle(compressorMenuItem); reconnect(); }; ytpPanelMenu.appendChild(compressorMenuItem); } reconnect(); } function attemptSetup() { 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 options added."); return true; } return false; } function trySetupLater() { console.debug("Polling for player..."); const intervalId = window.setInterval(() => { if (attemptSetup()) { window.clearInterval(intervalId); } }, 100); const cancelId = window.setTimeout(() => { window.clearInterval(intervalId); }, 10000); } if (!attemptSetup()) { trySetupLater(); window.addEventListener("yt-navigate-finish", trySetupLater); } })();