Adds a toggle to the Twitch player that compresses volume dynamics. This is a port of the audio compressor feature from https://github.com/crackededed/Xtra.
// ==UserScript==
// @name Twitch - AudioCompressor
// @namespace https://ioj4.net
// @version 1.0.1
// @match https://twitch.tv/*
// @match https://*.twitch.tv/*
// @grant none
// @license AGPL-3.0
// @author ioj4
// @description Adds a toggle to the Twitch player that compresses volume dynamics. This is a port of the audio compressor feature from https://github.com/crackededed/Xtra.
// ==/UserScript==
const UNIQUE_KEY="twitch-audio-compressor-injection";
const lazyQueries = new Set();
const observer = new MutationObserver(() => {
lazyQueries.forEach(e => {
const result = e.query();
if (!result) return;
if (e.once) lazyQueries.delete(e);
e.callback(result);
})
});
observer.observe(document.body, { subtree: true, attributes: true });
const lazySelector = (query, callback, once) => {
const result = query();
if (result) {
callback(result);
if (once) return;
}
const e = { query, callback, once };
lazyQueries.add(e);
return () => lazyQueries.delete(e);
}
class RemovedListener extends HTMLElement {
onRemoved;
connectedCallback() {
this.style.display = "contents";
}
disconnectedCallback() {
this.onRemoved?.();
}
}
customElements.define("removed-listener", RemovedListener);
// https://github.com/crackededed/Xtra/blob/377bfac17ff67f49f58593f79f17850693277aa0/app/src/main/res/drawable-xxhdpi/baseline_audio_compressor_off_24dp.png
const inactiveIcon = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAAAXNSR0IB2cksfwAAAARnQU1BAACxjwv8YQUAAAAgY0hSTQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+oEHgIeIaXh78cAAACdSURBVGje7ZdLDoAgDESBcP/r6sKkXsAofwZ8b9UFaTp2WsQ5AABZzMyU4icCbQKRWfnyas35rWfgvwJa2oAOKA76NAGjNxIWmj3oQamgkvz7W0hl3y/XgdQPxxZCQCVRtTDvvW8iIDURFhphod7dKMkf1QrCQqrbZrqAXnaKK/q+iQCV+4F/IYD3V9GRGZ+Z56+c80XPOqUYAECPG/S35yssDd9BAAAAAElFTkSuQmCC";
// https://github.com/crackededed/Xtra/blob/377bfac17ff67f49f58593f79f17850693277aa0/app/src/main/res/drawable-xxhdpi/baseline_audio_compressor_on_24dp.png
const activeIcon = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAAAXNSR0IB2cksfwAAAARnQU1BAACxjwv8YQUAAAAgY0hSTQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+oEHgIeDZc5gyQAAAD0SURBVGje7ZlLDsIwDEQzVsQS7n83jgIdNlmg8ksgrjzFXlVV5cxLnNhOSxE3zHZIEqWU5WEgAB4Apr4CCZAA0QAAUPIUIskBSIRbgV5RANCO2rB5gJ/Ezwwzl+TyDMJDvBvAGsIrC7vbyMb+W4PCDMqGYNrIZh0Jt9HvXTLxTBES1agXsKmHk0US9w2MqcS6XEfWO3HyLWWNmsZ7x60eTreseWo0QaEAemB+hazRBO3mGM2OLDuyHTQ5R5Ln9myS4ls5c23voCL+dCeeUhAvxOtAvBEfG4LkoUO8C4TX7fSy9q32m/WiXgtBHSCr0Syn0zayG+wXONlD65JKAAAAAElFTkSuQmCC";
const style = document.createElement("style");
style.innerText = `
.twitch-audio-compressor-toggle {
display: flex;
border-radius: 50%;
input {
appearance: none;
cursor: pointer;
width: var(--button-size-default, 3.2rem);
height: var(--button-size-default, 3.2rem);
background: url("${inactiveIcon}") center/75% no-repeat;
}
input:checked {
background: url("${activeIcon}") center/75% no-repeat;
}
}
.twitch-audio-compressor-toggle:hover {
background-color: var(--color-background-button-text-hover, rgba(255, 255, 255, 0.13));
}
`;
document.documentElement.append(style);
function addCompressor(s) {
// Initialization and Source
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const source = audioContext.createMediaElementSource(s.video);
// Hijack the player volume and apply it after the processing chain
const userGain = audioContext.createGain();
userGain.gain.value = s.video.volume;
s.video.volume = 1;
Object.defineProperty(s.video, "volume", {
set(v) {
userGain.gain.setValueAtTime(v, audioContext.currentTime);
s.video.dispatchEvent(new Event("volumechange"));
return v;
},
get() {
return userGain.gain.value;
}
});
// Dry
const dryGain = audioContext.createGain();
dryGain.gain.value = s.enabledInitially ? 0 : 1;
source.connect(dryGain);
dryGain.connect(userGain);
// Wet
const compressor = audioContext.createDynamicsCompressor();
// Parameters taken from:
// https://github.com/crackededed/Xtra/blob/377bfac17ff67f49f58593f79f17850693277aa0/app/src/main/java/com/github/andreyasadchy/xtra/ui/player/PlaybackService.kt#L583
compressor.threshold.value = -50;
compressor.knee.value = 40;
compressor.ratio.value = 12;
compressor.attack.value = 0;
compressor.release.value = 0.25;
source.connect(compressor);
const wetGain = audioContext.createGain();
wetGain.gain.value = s.enabledInitially ? 1 : 0;
compressor.connect(wetGain);
wetGain.connect(userGain);
// Destination
userGain.connect(audioContext.destination);
// Account for restrictive autoplay rules
const resumeIfNotRunning = () => {
if (audioContext.state !== "running") audioContext.resume();
}
document.addEventListener("mousedown", resumeIfNotRunning);
document.addEventListener("touchstart", resumeIfNotRunning);
s.update = (enabled) => {
dryGain.gain.linearRampToValueAtTime(enabled ? 0 : 1, audioContext.currentTime + 0.5);
wetGain.gain.linearRampToValueAtTime(enabled ? 1 : 0, audioContext.currentTime + 0.5);
}
}
function addToggle(s) {
// Reinsert toggle when needed
const removedListener = document.createElement("removed-listener");
removedListener.onRemoved = () => lazySelector(
() => s.player.querySelector(".player-controls__left-control-group"),
(newLeftControls) => addToggle({ ...s, leftControls: newLeftControls}),
true
);
const wrapper = document.createElement("div");
const toggle = document.createElement("input");
toggle.type = "checkbox";
toggle.checked = s.enabledInitially;
wrapper.classList.add("twitch-audio-compressor-toggle");
wrapper.appendChild(toggle);
removedListener.appendChild(wrapper);
const pauseButton = s.leftControls.firstElementChild;
pauseButton.insertAdjacentElement("afterend", removedListener);
toggle.addEventListener("change", (e) => {
const enabled = e.target.checked;
localStorage.setItem(`${UNIQUE_KEY}-enabled-initially`, enabled ? "1" : "0");
s.update?.(enabled); // Toggle between dry and wet
});
}
function patchPlayer(s) {
try {
s.leftControls = s.player.querySelector(".player-controls__left-control-group");
s.video = s.player.querySelector("video");
addCompressor(s);
addToggle(s);
} catch (e) {
console.warn(`[Twitch-Audio-Compressor] ${e}`);
}
}
lazySelector(
() => document.querySelectorAll(`div.video-player:has(.player-controls__left-control-group, video):not([${UNIQUE_KEY}])`),
(players) => {
const enabledInitially = localStorage.getItem(`${UNIQUE_KEY}-enabled-initially`) === "1";
players.forEach(player => {
player.setAttribute(UNIQUE_KEY, "");
const s = { player, enabledInitially };
patchPlayer(s);
});
},
false
);