backyard/sound/YouTube audio compressor.user.js

261 lines
15 KiB
JavaScript

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