New:Socket for Asana Is Now Available.Learn more
Get Started

YT-Levelr

Package Overview
Versions
2
Alerts
File Explorer
Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

yt-levelr@apmicro - firefox Package Compare versions

Comparing version
1.3.1
to
1.3.3
+75
-47
content.js

@@ -22,8 +22,8 @@ // Cross-browser compatibility shim: Firefox exposes `browser`, Chrome exposes `chrome`

const TARGET_RMS = 0.08; // Target RMS (~-22 dBFS, comfortable speech level)
const NOISE_FLOOR = 0.005; // Ignore samples quieter than this
const MAX_GAIN = 5.62; // +15dB absolute ceiling
const MIN_GAIN = 0.1; // -20dB absolute floor
const TARGET_RMS = 0.08; // Target RMS (~-22 dBFS, comfortable speech level)
const NOISE_FLOOR = 0.005; // Ignore samples quieter than this
const MAX_GAIN = 5.62; // +15dB absolute ceiling
const MIN_GAIN = 0.1; // -20dB absolute floor
const LOCK_TC = 30000; // ms - full confidence, lock gain
const LOCK_TC = 30000; // ms - full confidence, lock gain
const DRIFT_TC = 3 * 60 * 1000; // ms - slow drift correction period after lock

@@ -36,8 +36,14 @@

// Cuts apply faster than boosts to protect against sudden loud audio
const TC_CUT = 0.15;
const TC_CUT = 0.15;
const TC_BOOST = 1.2;
// Initial and full confidence gain limits for interpolation in gainLimitsForElapsed()
const GAIN_LIMIT_INITIAL = { maxCutDB: 6, maxBoostDB: 3 };
const GAIN_LIMIT_FULL = { maxCutDB: 20, maxBoostDB: 15 };
const GAIN_LIMIT_INITIAL = {
maxCutDB: 6,
maxBoostDB: 3,
};
const GAIN_LIMIT_FULL = {
maxCutDB: 20,
maxBoostDB: 15,
};

@@ -56,7 +62,7 @@ let audioCtx = null;

let measurementSamples = [];
let playingMs = 0; // cumulative ms of actual playback (excludes paused time)
let lastTickTime = null; // wall clock at last tick, for incrementing playingMs
let playingMs = 0; // cumulative ms of actual playback (excludes paused time)
let lastTickTime = null; // wall clock at last tick, for incrementing playingMs
let locked = false;
let currentGain = 1.0;
let intervalId = null; // setInterval handle for measurementLoop
let intervalId = null; // setInterval handle for measurementLoop
let lastDriftCorrection = null;

@@ -67,7 +73,10 @@

// Load enabled state from storage with error handling
browser.storage.local.get("enabled").then(result => {
enabled = result.enabled !== false; // default true
}).catch(err => {
console.warn("[YT Levelr] Failed to load enabled state:", err);
});
browser.storage.local
.get("enabled")
.then((result) => {
enabled = result.enabled !== false; // default true
})
.catch((err) => {
console.warn("[YT Levelr] Failed to load enabled state:", err);
});

@@ -104,3 +113,3 @@ // Listen for messages from popup

waveform,
gainLimits: limits
gainLimits: limits,
});

@@ -116,3 +125,3 @@ return true; // keeps the message channel open for sendResponse

const state = {
targetRMS: TARGET_RMS
targetRMS: TARGET_RMS,
};

@@ -143,7 +152,11 @@

// compressor/gain/analyser nodes without accumulating stale connections.
try { sourceNode.disconnect(); } catch(e) {}
try {
sourceNode.disconnect();
} catch {
// disconnect throws if already disconnected; safe to ignore
}
// Gentle compressor to tame transient peaks before gain adjustment
compressorNode = audioCtx.createDynamicsCompressor();
compressorNode.threshold.value = -18; // dB
compressorNode.threshold.value = -18; // dB
compressorNode.knee.value = 10;

@@ -161,7 +174,9 @@ compressorNode.ratio.value = 3;

// Graph: source -> compressor -> gain -> analyser -> destination
// Graph: source -> compressor -> analyser -> gain -> destination
// Analyser is upstream of gain so RMS measurements reflect true input level,
// not the already-adjusted output (which would cause the gain to chase itself).
sourceNode.connect(compressorNode);
compressorNode.connect(gainNode);
gainNode.connect(analyserNode);
analyserNode.connect(audioCtx.destination);
compressorNode.connect(analyserNode);
analyserNode.connect(gainNode);
gainNode.connect(audioCtx.destination);

@@ -176,3 +191,5 @@ log("Audio graph connected");

function getRMS() {
if (!analyserNode) return 0;
if (!analyserNode) {
return 0;
}
const buf = new Float32Array(analyserNode.fftSize);

@@ -205,8 +222,8 @@ try {

function gainLimitsForElapsed(elapsed) {
const t = Math.min(1, elapsed / LOCK_TC); // 0.0 at start, 1.0 at full confidence
const maxCutDB = GAIN_LIMIT_INITIAL.maxCutDB + t * (GAIN_LIMIT_FULL.maxCutDB - GAIN_LIMIT_INITIAL.maxCutDB);
const t = Math.min(1, elapsed / LOCK_TC); // 0.0 at start, 1.0 at full confidence
const maxCutDB = GAIN_LIMIT_INITIAL.maxCutDB + t * (GAIN_LIMIT_FULL.maxCutDB - GAIN_LIMIT_INITIAL.maxCutDB);
const maxBoostDB = GAIN_LIMIT_INITIAL.maxBoostDB + t * (GAIN_LIMIT_FULL.maxBoostDB - GAIN_LIMIT_INITIAL.maxBoostDB);
return {
min: Math.pow(10, -maxCutDB / 20),
max: Math.pow(10, maxBoostDB / 20),
min: Math.pow(10, -maxCutDB / 20),
max: Math.pow(10, maxBoostDB / 20),
};

@@ -218,6 +235,3 @@ }

const limits = gainLimitsForElapsed(elapsed !== undefined ? elapsed : LOCK_TC);
const clamped = Math.max(
Math.max(MIN_GAIN, limits.min),
Math.min(Math.min(MAX_GAIN, limits.max), g)
);
const clamped = Math.max(Math.max(MIN_GAIN, limits.min), Math.min(Math.min(MAX_GAIN, limits.max), g));

@@ -240,3 +254,5 @@ const isCut = clamped < currentGain;

// which callers should treat the same as paused.
if (!videoEl || videoEl.muted || videoEl.volume < 0.05) return null;
if (!videoEl || videoEl.muted || videoEl.volume < 0.05) {
return null;
}
return videoEl.volume;

@@ -250,3 +266,5 @@ }

function measurementLoop() {
if (!analyserNode || !enabled) return;
if (!analyserNode || !enabled) {
return;
}

@@ -290,3 +308,5 @@ // Resume AudioContext if it was suspended (e.g. browser autoplay policy)

// Skip silence
if (trueRMS < NOISE_FLOOR) return;
if (trueRMS < NOISE_FLOOR) {
return;
}

@@ -304,3 +324,5 @@ if (!locked) {

applyGain(targetGain, playingMs);
log(`Gain update at ${(playingMs/1000).toFixed(1)}s playing: ${currentGain.toFixed(3)}x (true RMS: ${medianRMS.toFixed(4)}, volume: ${volumeScale.toFixed(2)})`);
log(
`Gain update at ${(playingMs / 1000).toFixed(1)}s playing: ${currentGain.toFixed(3)}x (true RMS: ${medianRMS.toFixed(4)}, volume: ${volumeScale.toFixed(2)})`,
);
}

@@ -336,8 +358,8 @@

function median(arr) {
if (arr.length === 0) return 0;
if (arr.length === 0) {
return 0;
}
const sorted = [...arr].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 !== 0
? sorted[mid]
: (sorted[mid - 1] + sorted[mid]) / 2;
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}

@@ -358,3 +380,3 @@

// AudioContext construction; a play event satisfies this requirement.
waitForVideo().then(el => {
waitForVideo().then((el) => {
videoEl = el;

@@ -366,3 +388,3 @@

setupAudioGraph(videoEl);
} catch (err) {
} catch {
// setupAudioGraph already logged; don't crash the whole listener

@@ -374,3 +396,5 @@ return;

}
if (intervalId) clearInterval(intervalId);
if (intervalId) {
clearInterval(intervalId);
}
intervalId = setInterval(measurementLoop, 300);

@@ -383,3 +407,5 @@ };

} else {
videoEl.addEventListener("play", initGraph, { once: true });
videoEl.addEventListener("play", initGraph, {
once: true,
});
}

@@ -390,6 +416,8 @@ });

function waitForVideo() {
return new Promise(resolve => {
return new Promise((resolve) => {
const check = () => {
const el = document.querySelector("video");
if (el) return resolve(el);
if (el) {
return resolve(el);
}
setTimeout(check, 200);

@@ -396,0 +424,0 @@ };

{
"manifest_version": 3,
"name": "YT Levelr",
"version": "1.3.1",
"version": "1.3.3",
"description": "Automatic loudness levelling for YouTube \u2014 useful for podcasts",

@@ -6,0 +6,0 @@ "homepage_url": "https://github.com/AndyP2/yt-levelr",

@@ -33,2 +33,9 @@ <!DOCTYPE html>

@media (max-width: 480px) {
body {
width: 100%;
max-width: 100%;
}
}
header {

@@ -35,0 +42,0 @@ padding: 14px 16px 12px;

+154
-97

@@ -11,3 +11,5 @@ // Cross-browser compatibility shim: Firefox exposes `browser`, Chrome exposes `chrome`

function rmsToDb(rms) {
if (rms <= 0) return -96;
if (rms <= 0) {
return -96;
}
return 20 * Math.log10(rms);

@@ -31,6 +33,5 @@ }

const canvas = document.getElementById("waveform-canvas");
const waveformWrap = document.getElementById("waveform-wrap");
const ctx = canvas.getContext("2d");
const CW = canvas.width; // 228
const CH = canvas.height; // 88
const CW = canvas.width; // 228
const CH = canvas.height; // 88

@@ -49,12 +50,12 @@ // Y axis: -48dBFS (bottom) to 0dBFS (top), linear in dB

// Colours matching CSS vars
const C_BG = "#1a1a1a";
const C_GRID = "#242424";
const C_GRID_TEXT = "#444";
const C_WAVEFORM = "rgba(200,240,96,0.25)";
const C_BG = "#1a1a1a";
const C_GRID = "#242424";
const C_GRID_TEXT = "#444";
const C_WAVEFORM = "rgba(200,240,96,0.25)";
const C_WAVEFORM_LINE = "rgba(200,240,96,0.55)";
const C_TARGET = "rgba(200,240,96,0.3)";
const C_BAND = "rgba(200,240,96,0.07)";
const C_BAND_EDGE = "rgba(200,240,96,0.2)";
const C_GAIN_LINE = "#c8f060";
const C_NULL = "rgba(255,255,255,0.04)";
const C_TARGET = "rgba(200,240,96,0.3)";
const C_BAND = "rgba(200,240,96,0.07)";
const C_BAND_EDGE = "rgba(200,240,96,0.2)";
const C_GAIN_LINE = "#c8f060";
const C_NULL = "rgba(255,255,255,0.04)";

@@ -112,6 +113,6 @@ function drawWaveform(state) {

const targetDb = rmsToDb(state.targetRMS);
const cutDB = gainToDb(1 / state.gainLimits.min);
const boostDB = gainToDb(state.gainLimits.max);
const bandTop = dbToY(targetDb + boostDB);
const bandBot = dbToY(targetDb - cutDB);
const cutDB = gainToDb(1 / state.gainLimits.min);
const boostDB = gainToDb(state.gainLimits.max);
const bandTop = dbToY(targetDb + boostDB);
const bandBot = dbToY(targetDb - cutDB);

@@ -125,4 +126,6 @@ ctx.fillStyle = C_BAND;

ctx.beginPath();
ctx.moveTo(0, bandTop); ctx.lineTo(CW, bandTop);
ctx.moveTo(0, bandBot); ctx.lineTo(CW, bandBot);
ctx.moveTo(0, bandTop);
ctx.lineTo(CW, bandTop);
ctx.moveTo(0, bandBot);
ctx.lineTo(CW, bandBot);
ctx.stroke();

@@ -136,3 +139,6 @@ ctx.setLineDash([]);

for (let i = 0; i < N; i++) {
if (waveform[i] === null) { started = false; continue; }
if (waveform[i] === null) {
started = false;
continue;
}
const x = i * colW;

@@ -159,7 +165,14 @@ const y = dbToY(rmsToDb(waveform[i]));

for (let i = 0; i < N; i++) {
if (waveform[i] === null) { started = false; continue; }
if (waveform[i] === null) {
started = false;
continue;
}
const x = i * colW + colW / 2;
const y = dbToY(rmsToDb(waveform[i]));
if (!started) { ctx.moveTo(x, y); started = true; }
else ctx.lineTo(x, y);
if (!started) {
ctx.moveTo(x, y);
started = true;
} else {
ctx.lineTo(x, y);
}
}

@@ -191,13 +204,13 @@ ctx.strokeStyle = C_WAVEFORM_LINE;

const toggleEl = document.getElementById("enabled-toggle");
const toggleLabel = document.getElementById("toggle-label");
const gainDisplay = document.getElementById("gain-display");
const gainBar = document.getElementById("gain-bar");
const statusDot = document.getElementById("status-dot");
const statusText = document.getElementById("status-text");
const toggleEl = document.getElementById("enabled-toggle");
const toggleLabel = document.getElementById("toggle-label");
const gainDisplay = document.getElementById("gain-display");
const gainBar = document.getElementById("gain-bar");
const statusDot = document.getElementById("status-dot");
const statusText = document.getElementById("status-text");
const confidenceBar = document.getElementById("confidence-bar");
const confidencePct = document.getElementById("confidence-pct");
const targetSlider = document.getElementById("target-slider");
const targetVal = document.getElementById("target-val");
const remeasureBtn = document.getElementById("remeasure-btn");
const targetSlider = document.getElementById("target-slider");
const targetVal = document.getElementById("target-val");
const remeasureBtn = document.getElementById("remeasure-btn");

@@ -214,4 +227,4 @@ // ---- Confidence ----

browser.storage.local.get(["enabled", "targetDB"]).then(result => {
const enabled = result.enabled !== false;
browser.storage.local.get(["enabled", "targetDB"]).then((result) => {
const enabled = result.enabled !== false;
const targetDB = result.targetDB !== undefined ? result.targetDB : -22;

@@ -229,65 +242,79 @@

let pollInterval = null; // Track interval for cleanup
let lastPollTime = 0; // Last poll timestamp for debouncing
const POLL_INTERVAL_MS = 1000; // Reduced from 800ms to 1000ms for better performance
const POLL_INTERVAL_MS = 1000;
const POLL_DEBOUNCE_MS = 500; // Debounce polling when inactive
let lastPollTime = 0; // Last poll timestamp for debouncing
function pollState() {
browser.tabs.query({ active: true, currentWindow: true }).then(tabs => {
if (!tabs[0]) return;
browser.tabs
.query({
active: true,
})
.then((tabs) => {
if (!tabs[0]) {
return;
}
const now = Date.now();
const now = Date.now();
// Debounce polling when tab is inactive
if (now - lastPollTime < POLL_DEBOUNCE_MS) {
return;
}
lastPollTime = now;
// Debounce polling when tab is inactive
if (now - lastPollTime < POLL_DEBOUNCE_MS) {
return;
}
lastPollTime = now;
browser.tabs.sendMessage(tabs[0].id, { type: "getState" }).then(state => {
if (!state) return;
browser.tabs
.sendMessage(tabs[0].id, {
type: "getState",
})
.then((state) => {
if (!state) {
return;
}
const gainDb = gainToDb(state.gain);
gainDisplay.textContent = state.gain.toFixed(2);
// Restore the unit span safely
const unitSpan = document.createElement("span");
unitSpan.className = "unit";
unitSpan.textContent = "x";
gainDisplay.appendChild(unitSpan);
gainBar.style.width = gainToBarPercent(state.gain) + "%";
const gainDb = gainToDb(state.gain);
gainDisplay.textContent = state.gain.toFixed(2);
// Restore the unit span safely
const unitSpan = document.createElement("span");
unitSpan.className = "unit";
unitSpan.textContent = "x";
gainDisplay.appendChild(unitSpan);
gainBar.style.width = gainToBarPercent(state.gain) + "%";
if (!state.enabled) {
statusDot.className = "status-dot off";
statusText.textContent = "disabled";
confidenceBar.className = "confidence-bar-fill";
confidenceBar.style.width = "0%";
confidencePct.textContent = "—";
} else if (state.locked) {
statusDot.className = "status-dot locked";
statusText.textContent = `locked · ${gainDb >= 0 ? "+" : ""}${gainDb.toFixed(1)} dB`;
confidenceBar.className = "confidence-bar-fill locked";
confidencePct.textContent = "locked";
} else {
const pct = elapsedToConfidence(state.elapsed);
statusDot.className = "status-dot measuring";
statusText.textContent = "measuring…";
confidenceBar.className = "confidence-bar-fill";
confidenceBar.style.width = pct + "%";
confidencePct.textContent = Math.round(pct) + "%";
}
if (!state.enabled) {
statusDot.className = "status-dot off";
statusText.textContent = "disabled";
confidenceBar.className = "confidence-bar-fill";
confidenceBar.style.width = "0%";
confidencePct.textContent = "—";
} else if (state.locked) {
statusDot.className = "status-dot locked";
statusText.textContent = `locked · ${gainDb >= 0 ? "+" : ""}${gainDb.toFixed(1)} dB`;
confidenceBar.className = "confidence-bar-fill locked";
confidencePct.textContent = "locked";
} else {
const pct = elapsedToConfidence(state.elapsed);
statusDot.className = "status-dot measuring";
statusText.textContent = "measuring…";
confidenceBar.className = "confidence-bar-fill";
confidenceBar.style.width = pct + "%";
confidencePct.textContent = Math.round(pct) + "%";
}
drawWaveform(state);
}).catch(err => {
console.log("[YT Levelr popup] sendMessage failed:", err.message);
statusDot.className = "status-dot off";
statusText.textContent = "not on a YouTube video";
confidenceBar.className = "confidence-bar-fill";
confidenceBar.style.width = "0%";
confidencePct.textContent = "\u2014";
drawWaveform({ waveform: new Array(100).fill(null) });
drawWaveform(state);
})
.catch((err) => {
console.log("[YT Levelr popup] sendMessage failed:", err.message);
statusDot.className = "status-dot off";
statusText.textContent = "not on a YouTube video";
confidenceBar.className = "confidence-bar-fill";
confidenceBar.style.width = "0%";
confidencePct.textContent = "\u2014";
drawWaveform({
waveform: new Array(100).fill(null),
});
});
})
.catch((err) => {
console.warn("[YT Levelr popup] tabs.query failed:", err);
});
}).catch(err => {
console.warn("[YT Levelr popup] tabs.query failed:", err);
});
}

@@ -297,3 +324,3 @@

pollState();
pollInterval = setInterval(pollState, POLL_INTERVAL_MS);
setInterval(pollState, POLL_INTERVAL_MS);

@@ -306,6 +333,17 @@ // ---- Controls ----

document.body.classList.toggle("disabled", !enabled);
browser.storage.local.set({ enabled });
browser.tabs.query({ active: true, currentWindow: true }).then(tabs => {
if (tabs[0]) browser.tabs.sendMessage(tabs[0].id, { type: "setEnabled", value: enabled });
browser.storage.local.set({
enabled,
});
browser.tabs
.query({
active: true,
})
.then((tabs) => {
if (tabs[0]) {
browser.tabs.sendMessage(tabs[0].id, {
type: "setEnabled",
value: enabled,
});
}
});
});

@@ -317,12 +355,31 @@

const rms = dbToRMS(db);
browser.storage.local.set({ targetDB: db });
browser.tabs.query({ active: true, currentWindow: true }).then(tabs => {
if (tabs[0]) browser.tabs.sendMessage(tabs[0].id, { type: "setTarget", value: rms });
browser.storage.local.set({
targetDB: db,
});
browser.tabs
.query({
active: true,
})
.then((tabs) => {
if (tabs[0]) {
browser.tabs.sendMessage(tabs[0].id, {
type: "setTarget",
value: rms,
});
}
});
});
remeasureBtn.addEventListener("click", () => {
browser.tabs.query({ active: true, currentWindow: true }).then(tabs => {
if (tabs[0]) browser.tabs.sendMessage(tabs[0].id, { type: "remeasure" });
});
browser.tabs
.query({
active: true,
})
.then((tabs) => {
if (tabs[0]) {
browser.tabs.sendMessage(tabs[0].id, {
type: "remeasure",
});
}
});
statusDot.className = "status-dot measuring";

@@ -329,0 +386,0 @@ statusText.textContent = "measuring…";

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet