From 869c3e94333ffb9848ab686074910cf4fb36f528 Mon Sep 17 00:00:00 2001 From: Connor Olding Date: Thu, 15 Feb 2024 17:20:15 -0800 Subject: [PATCH] sound: update YouTube audio compressor --- sound/YouTube audio compressor.md | 25 +++ sound/YouTube audio compressor.user.js | 247 ++++++++++++++++--------- sound/limiter.js | 175 ++++++++++++++++++ 3 files changed, 355 insertions(+), 92 deletions(-) create mode 100644 sound/YouTube audio compressor.md create mode 100644 sound/limiter.js diff --git a/sound/YouTube audio compressor.md b/sound/YouTube audio compressor.md new file mode 100644 index 0000000..98c03bd --- /dev/null +++ b/sound/YouTube audio compressor.md @@ -0,0 +1,25 @@ +assuming you already have a browser addon such as Greasemonkey, Tampermonkey, or Violentmonkey installed, simply + +## [click here to install](https://gist.github.com/notwa/9b8466b0c2ca48d756afcd02a5e43739/raw/YouTube%2520audio%2520compressor.user.js) + +![what it looks like when installed](https://user-images.githubusercontent.com/1459466/281552372-282b2d47-ebe9-4a64-996c-60d8fc3905f4.png) + +this userscript is a heavily customized fork of [Vivelin's original userscript](https://gist.github.com/Vivelin/2321d17bf26016ceaed87d6d1a281881) +and integrates [a limiter written by Wareya](https://github.com/wareya/LimiterTest) that i've ported to JavaScript. + +## usage + +click on the gear symbol near the bottom right of YouTube's video player to access these features: + +### Level Boost + +this adds +10 decibels of gain to the audio, as well as a brickwall limiter to reduce unwanted distortion (clipping). + +this works in conjunction with YouTube's volume slider, so if +10 dB is a little too much, simply turn the volume down. + +### Compressor + +in terms of volume, this squeezes the quiet and loud parts closer together to allow for a more uniform listening experience. + +this compressor has been tuned to be less responsive to bass and treble frequencies, which helps to reduce "pumping" artifacts. +note that the level boost feeds into the compressor, so the amount of compression somewhat increases with both features enabled. diff --git a/sound/YouTube audio compressor.user.js b/sound/YouTube audio compressor.user.js index 1000d41..2546811 100644 --- a/sound/YouTube audio compressor.user.js +++ b/sound/YouTube audio compressor.user.js @@ -1,35 +1,47 @@ // ==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 +// @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== -(function () { - 'use strict'; +(() => { + "use strict"; - let active = false; - let audioContext = null; + // Boolean: + let boosterActive = false; + let compressorActive = false; + let fancyCompression = true; + + // AudioContext: + let context = null; + + // AudioNode: let source = null; - let compressor = 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, the zeros of an IIR are not subject to this constraint. + // 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, - // with their numerators and denominators swapped. + // 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 @@ -41,7 +53,6 @@ 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, @@ -59,139 +70,191 @@ const a2 = coeffs[flip ? 2 : 4]; const num = [b0, b1, b2]; const den = [a0, a1, a2]; - const filter = audioContext.createIIRFilter(num, den); + const filter = context.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'); + 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'; + 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'; + const label = document.createElement("div"); + label.className = "ytp-menuitem-label"; + label.textContent = title; menuItem.appendChild(label); - const content = document.createElement('div'); - content.className = 'ytp-menuitem-content'; - const toggleCheckbox = document.createElement('div'); - toggleCheckbox.className = 'ytp-menuitem-toggle-checkbox'; + 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 setupCompressor(player, ytpPanelMenu) { - if (!audioContext) { - audioContext = new AudioContext(); + 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); } - const iir = iirs[audioContext.sampleRate]; - if (typeof iir === "undefined") { - fancy = false; + 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 = audioContext.createMediaElementSource(player); + source = context.createMediaElementSource(player); } if (!gainIn) { - gainIn = audioContext.createGain(); - gainIn.gain.setValueAtTime(0.7, audioContext.currentTime); + gainIn = context.createGain(); + gainIn.gain.setValueAtTime(1.0, now); } - if (!filterIn && fancy) { + if (!filterIn && fancyCompression) { 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); + 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 && fancy) { + if (!filterOut && fancyCompression) { filterOut = createFilter(iir, true, 0.5); } if (!gainOut) { - gainOut = audioContext.createGain(); - gainOut.gain.setValueAtTime(0.7, audioContext.currentTime); + gainOut = context.createGain(); + gainOut.gain.setValueAtTime(1.0, now); } - 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 (!boosterMenuItem) { + boosterMenuItem = createMenuItem("Level Boost", boosterActive); + boosterMenuItem.onclick = () => { + boosterActive = ariaToggle(boosterMenuItem); + reconnect(); + }; + ytpPanelMenu.appendChild(boosterMenuItem); } 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; - } + compressorMenuItem = createMenuItem("Compressor", compressorActive); + compressorMenuItem.onclick = () => { + compressorActive = ariaToggle(compressorMenuItem); + reconnect(); }; + ytpPanelMenu.appendChild(compressorMenuItem); } + + reconnect(); } - function startSetup() { - const player = document.querySelector('#ytd-player video'); - const ytpPanelMenu = document.querySelector('#ytd-player .ytp-settings-menu .ytp-panel-menu'); + 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 option added'); + console.info("Compressor options added."); return true; } return false; } - function trySetup() { - console.debug('Polling for player...'); - const intervalId = window.setInterval(function () { - if (startSetup()) { + function trySetupLater() { + console.debug("Polling for player..."); + + const intervalId = window.setInterval(() => { + if (attemptSetup()) { window.clearInterval(intervalId); } }, 100); - const cancelId = window.setTimeout(function () { + const cancelId = window.setTimeout(() => { window.clearInterval(intervalId); }, 10000); } - if (!startSetup()) { - trySetup(); - - window.addEventListener('yt-navigate-finish', function () { - trySetup(); - }); + if (!attemptSetup()) { + trySetupLater(); + window.addEventListener("yt-navigate-finish", trySetupLater); } -})(); \ No newline at end of file +})(); diff --git a/sound/limiter.js b/sound/limiter.js new file mode 100644 index 0000000..5dee695 --- /dev/null +++ b/sound/limiter.js @@ -0,0 +1,175 @@ +// port of https://github.com/wareya/LimiterTest/blob/main/Main.gd + +class Limiter extends AudioWorkletProcessor { + constructor(options) { + super(); + + this.srate = sampleRate; + + this.pre_gain = 1.0; + this.post_gain = 1.0; + this.limit = 0.8912509381337456; // -1 decibels + + if (false) { + this.attack = 0.0010; + this.sustain = 0.0400; + this.release = 0.0400; + } else { // ultrafast + this.attack = 0.0006; + this.sustain = 0.0060; + this.release = 0.0300; + } + + this.release_memory = 0.0; + + this.box_blur = 0; + // this.amp = 1.0; // not stateful + this.ref_amp = 1.0; + + this.memory_cursor = 0; + this.sustained_amp = null; + this.sample_memory = null; + + this.amp_memory_cursor = 0; + this.amp_memory = null; + this.amp_min_buckets = null; + this.amp_bucket_size = 0; + + this.peaks = null; + this.delays = []; + + const toSamples = (t) => Math.max(Math.round(this.srate * t), 1); + + let memory_count = toSamples(this.attack); + this.sample_memory = new Float32Array(memory_count); + this.sustained_amp = new Float32Array(memory_count); + + let amp_count = toSamples(this.attack + this.sustain); + this.amp_bucket_size = Math.sqrt(amp_count) | 0; + this.amp_memory = new Float32Array(amp_count); + this.amp_memory.fill(1.0); + + let amp_bucket_count = amp_count / this.amp_bucket_size | 0; + this.amp_min_buckets = new Float32Array(amp_bucket_count); + this.amp_min_buckets.fill(1.0); + + this.peaks = new Float32Array(128); // guess the buffer size + this.delays = [this.sample_memory]; + } + + process(inputs, outputs, parameters) { + const memory_count = this.sample_memory.length; // this shows up a lot + + const input = inputs[0]; + const output = outputs[0]; + + if (input.length === 0) { + return false; + } + + while (this.delays.length < input.length) { + this.delays.push(new Float32Array(memory_count)); + } + + const blockSize = input[0].length; + if (blockSize > 0 && this.peaks.length !== blockSize) { + this.peaks = new Float32Array(blockSize); + } + this.peaks.fill(0.0); + + for (let c = 0; c < input.length; c++) { + const source = input[c]; + for (let i = 0; i < blockSize; i++) { + this.peaks[i] = Math.max(this.peaks[i], Math.abs(source[i])); + } + } + + const delay_cursor = this.memory_cursor; + + for (let i = 0; i < blockSize; i++) { + const sample = this.peaks[i] * this.pre_gain; + + // do the release curve + this.release_memory = Math.max(0.0, this.release_memory - 1.0 / this.srate); + let amp = 1.0; + if (this.release > 0.0) { + let t = 1.0 - this.release_memory / this.release; + amp = (1.0 - t) * this.ref_amp + t; + } + + // reset sustain if we exceed the limit + let ref_val = Math.abs(amp * sample); + if (ref_val >= this.limit) { + let amount = this.limit / ref_val; + amp *= amount; + ref_val *= amount; + this.ref_amp = amp; + this.release_memory = this.release; + } + + this.amp_memory[this.amp_memory_cursor] = amp; + + // update affected bucket + let bucket = this.amp_memory_cursor / this.amp_bucket_size | 0; + this.amp_min_buckets[bucket] = 1.0; + let end = Math.min((bucket + 1) * this.amp_bucket_size, this.amp_memory.length); + for (let j = bucket * this.amp_bucket_size; j < end; j++) { + this.amp_min_buckets[bucket] = Math.min(this.amp_min_buckets[bucket], this.amp_memory[j]); + } + + // now get the current sustain value + let min_amp = 1.0; + for (let j = 0; j < this.amp_min_buckets.length; j++) { + let past_amp = this.amp_min_buckets[j]; + min_amp = Math.min(min_amp, past_amp); + } + + this.amp_memory_cursor += 1; + this.amp_memory_cursor %= this.amp_memory.length; + + // update the sustain buffer + this.box_blur -= this.sustained_amp[this.memory_cursor] * 32767 | 0; + this.sustained_amp[this.memory_cursor] = min_amp; + this.box_blur += this.sustained_amp[this.memory_cursor] * 32767 | 0; + + // update the sample memory buffer + // NOTE(notwa): we instead do this later for each channel. + //let ret_sample = this.sustain + this.attack > 0.0 ? this.sample_memory[this.memory_cursor] : sample; + //this.sample_memory[this.memory_cursor] = sample; + + this.memory_cursor += 1; + this.memory_cursor %= memory_count; + + this.peaks[i] = this.box_blur / 32767 / memory_count; + } + + const final_gain = this.pre_gain * this.post_gain; + for (let c = 0; c < input.length; c++) { + const source = input[c]; + const target = output[c]; + if (this.sustain + this.attack <= 0.0) { + for (let i = 0; i < blockSize; i++) { + // return the limited sample + target[i] = source[i] * this.peaks[i] * final_gain; + } + } else { + const delay = this.delays[c]; + let local_cursor = delay_cursor; + for (let i = 0; i < blockSize; i++) { + const ret_sample = delay[local_cursor]; + delay[local_cursor] = source[i]; + + local_cursor += 1; + local_cursor %= memory_count; + + // return the limited sample + target[i] = ret_sample * this.peaks[i] * final_gain; + } + } + } + + return false; // docs say to return false for processors with no reverb tails + } +} + +registerProcessor("limiter", Limiter);