backyard/sound/YouTube audio compressor.user.js

197 lines
No EOL
6.4 KiB
JavaScript

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