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.0.0
to
1.1.2
+132
-81
content.js

@@ -61,5 +61,7 @@ /**

// Load enabled state from storage
// 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);
});

@@ -69,33 +71,36 @@

browser.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "setEnabled") {
enabled = msg.value;
if (gainNode) {
gainNode.gain.setTargetAtTime(enabled ? currentGain : 1.0, audioCtx.currentTime, 0.1);
try {
if (msg.type === "setEnabled") {
enabled = msg.value;
if (gainNode && audioCtx) {
gainNode.gain.setTargetAtTime(enabled ? currentGain : 1.0, audioCtx.currentTime, 0.1);
}
}
}
if (msg.type === "setTarget") {
// TARGET_RMS is const but we can work around this via a mutable wrapper
state.targetRMS = msg.value;
}
if (msg.type === "remeasure") {
resetMeasurement();
}
if (msg.type === "getState") {
// Unroll ring buffer into chronological order
const waveform = [];
for (let i = 0; i < WAVEFORM_SIZE; i++) {
waveform.push(waveformHistory[(waveformHead + i) % WAVEFORM_SIZE]);
if (msg.type === "setTarget") {
// TARGET_RMS is const but we can work around this via a mutable wrapper
state.targetRMS = msg.value;
}
const limits = gainLimitsForElapsed(locked ? LOCK_TC : playingMs);
sendResponse({
enabled,
gain: currentGain,
locked,
targetRMS: state.targetRMS,
elapsed: playingMs,
waveform,
gainLimits: limits,
paused: videoEl ? videoEl.paused : true
});
return true; // keeps the message channel open for sendResponse
if (msg.type === "remeasure") {
resetMeasurement();
}
if (msg.type === "getState") {
// Unroll ring buffer into chronological order
const waveform = [];
for (let i = 0; i < WAVEFORM_SIZE; i++) {
waveform.push(waveformHistory[(waveformHead + i) % WAVEFORM_SIZE]);
}
const limits = gainLimitsForElapsed(locked ? LOCK_TC : playingMs);
sendResponse({
enabled,
gain: currentGain,
locked,
targetRMS: state.targetRMS,
elapsed: playingMs,
waveform,
gainLimits: limits
});
return true; // keeps the message channel open for sendResponse
}
} catch (err) {
console.error("[YT Levelr] Message handler error:", err);
}

@@ -114,36 +119,41 @@ });

function setupAudioGraph(videoEl) {
if (audioCtx) {
try { audioCtx.close(); } catch(e) {}
}
try {
if (audioCtx) {
try { audioCtx.close(); } catch(e) {}
}
audioCtx = new AudioContext();
audioCtx = new AudioContext();
// AudioContext may start suspended if created before a user gesture.
// resume() is a no-op if already running, so safe to call unconditionally.
audioCtx.resume().catch(() => {});
// AudioContext may start suspended if created before a user gesture.
// resume() is a no-op if already running, so safe to call unconditionally.
audioCtx.resume().catch(() => {});
sourceNode = audioCtx.createMediaElementSource(videoEl);
sourceNode = audioCtx.createMediaElementSource(videoEl);
// Gentle compressor to tame transient peaks before gain adjustment
compressorNode = audioCtx.createDynamicsCompressor();
compressorNode.threshold.value = -18; // dB
compressorNode.knee.value = 10;
compressorNode.ratio.value = 3;
compressorNode.attack.value = 0.05;
compressorNode.release.value = 0.3;
// Gentle compressor to tame transient peaks before gain adjustment
compressorNode = audioCtx.createDynamicsCompressor();
compressorNode.threshold.value = -18; // dB
compressorNode.knee.value = 10;
compressorNode.ratio.value = 3;
compressorNode.attack.value = 0.05;
compressorNode.release.value = 0.3;
gainNode = audioCtx.createGain();
gainNode.gain.value = enabled ? currentGain : 1.0;
gainNode = audioCtx.createGain();
gainNode.gain.value = enabled ? currentGain : 1.0;
analyserNode = audioCtx.createAnalyser();
analyserNode.fftSize = 2048;
analyserNode.smoothingTimeConstant = 0.8;
analyserNode = audioCtx.createAnalyser();
analyserNode.fftSize = 2048;
analyserNode.smoothingTimeConstant = 0.8;
// Graph: source -> compressor -> gain -> analyser -> destination
sourceNode.connect(compressorNode);
compressorNode.connect(gainNode);
gainNode.connect(analyserNode);
analyserNode.connect(audioCtx.destination);
// Graph: source -> compressor -> gain -> analyser -> destination
sourceNode.connect(compressorNode);
compressorNode.connect(gainNode);
gainNode.connect(analyserNode);
analyserNode.connect(audioCtx.destination);
log("Audio graph connected");
log("Audio graph connected");
} catch (err) {
console.error("[YT Levelr] Failed to setup audio graph:", err);
throw err;
}
}

@@ -154,3 +164,8 @@

const buf = new Float32Array(analyserNode.fftSize);
analyserNode.getFloatTimeDomainData(buf);
try {
analyserNode.getFloatTimeDomainData(buf);
} catch (err) {
console.warn("[YT Levelr] Failed to get time domain data:", err);
return 0;
}
let sum = 0;

@@ -191,20 +206,33 @@ for (let i = 0; i < buf.length; i++) {

function applyGain(g, elapsed) {
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)
);
try {
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 isCut = clamped < currentGain;
const tc = isCut ? TC_CUT : TC_BOOST;
const isCut = clamped < currentGain;
const tc = isCut ? TC_CUT : TC_BOOST;
currentGain = clamped;
if (gainNode && enabled) {
gainNode.gain.setTargetAtTime(currentGain, audioCtx.currentTime, tc);
currentGain = clamped;
if (gainNode && enabled) {
gainNode.gain.setTargetAtTime(currentGain, audioCtx.currentTime, tc);
}
} catch (err) {
console.error("[YT Levelr] Failed to apply gain:", err);
}
try {
browser.runtime.sendMessage({ type: "gainUpdate", gain: currentGain, locked });
} catch(e) {}
}
function getVolumeScale() {
// Returns the current volume as a linear scale factor (0.05..1.0).
// Returns null if the video is muted or below the minimum usable threshold,
// which callers should treat the same as paused.
if (!videoEl || videoEl.muted || videoEl.volume < 0.05) return null;
return videoEl.volume;
}
function isEffectivelyMuted() {
return getVolumeScale() === null;
}
function measurementLoop() {

@@ -215,3 +243,7 @@ if (!analyserNode || !enabled) return;

if (audioCtx && audioCtx.state === "suspended") {
audioCtx.resume().catch(() => {});
try {
audioCtx.resume();
} catch (err) {
console.warn("[YT Levelr] Failed to resume AudioContext:", err);
}
return;

@@ -222,4 +254,4 @@ }

// Accumulate playing time only while the video is actually running
if (videoEl && !videoEl.paused && !videoEl.ended) {
// Accumulate playing time only while the video is actually running and audible
if (videoEl && !videoEl.paused && !videoEl.ended && !isEffectivelyMuted()) {
if (lastTickTime !== null) {

@@ -230,5 +262,3 @@ playingMs += now - lastTickTime;

} else {
// Paused -- push a null sample so the waveform shows the gap, then stop
waveformHistory[waveformHead] = null;
waveformHead = (waveformHead + 1) % WAVEFORM_SIZE;
// Paused or muted -- stop the clock without advancing the waveform
lastTickTime = null;

@@ -239,12 +269,18 @@ return;

const rms = getRMS();
const volumeScale = getVolumeScale() ?? 1.0; // defensive fallback, unreachable in practice
// Pre-scale RMS by 1/volume to get the video's inherent signal level,
// independent of where the user has the volume set.
// The waveform also uses this so the display reflects true content loudness.
const trueRMS = rms / volumeScale;
// Always record to waveform history regardless of noise floor
waveformHistory[waveformHead] = rms;
waveformHistory[waveformHead] = trueRMS;
waveformHead = (waveformHead + 1) % WAVEFORM_SIZE;
// Skip silence
if (rms < NOISE_FLOOR) return;
if (trueRMS < NOISE_FLOOR) return;
if (!locked) {
measurementSamples.push(rms);
measurementSamples.push(trueRMS);

@@ -256,5 +292,6 @@ // Apply gain on every tick from the first sample onward.

const medianRMS = median(measurementSamples);
// Gain is set purely from true signal level -- volume slider scales output naturally
const targetGain = state.targetRMS / medianRMS;
applyGain(targetGain, playingMs);
log(`Gain update at ${(playingMs/1000).toFixed(1)}s playing: ${currentGain.toFixed(3)}x (median RMS: ${medianRMS.toFixed(4)})`);
log(`Gain update at ${(playingMs/1000).toFixed(1)}s playing: ${currentGain.toFixed(3)}x (true RMS: ${medianRMS.toFixed(4)}, volume: ${volumeScale.toFixed(2)})`);
}

@@ -273,5 +310,6 @@

if (timeSinceDrift >= DRIFT_TC) {
measurementSamples.push(rms);
measurementSamples.push(trueRMS);
if (measurementSamples.length >= 60) {
const medianRMS = median(measurementSamples);
// medianRMS is already pre-scaled to true signal level, so no volume factor needed
const targetGain = state.targetRMS / medianRMS;

@@ -330,2 +368,7 @@ // Blend 10% toward new target, with full gain range permitted

// Handle YouTube UI changes that might affect our listeners
window.addEventListener("yt-navigate-start", () => {
log("YouTube navigation starting...");
});
// YouTube fires this on SPA navigation

@@ -343,1 +386,9 @@ window.addEventListener("yt-navigate-finish", () => {

}
// Gracefully handle video element removal
window.addEventListener("beforeunload", () => {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
});
{
"manifest_version": 3,
"name": "YT Levelr",
"version": "1.0.0",
"version": "1.1.2",
"description": "Automatic loudness levelling for YouTube \u2014 useful for podcasts",

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

@@ -365,21 +365,2 @@ <!DOCTYPE html>

}
.waveform-paused-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-family: 'DM Mono', monospace;
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.08em;
text-transform: uppercase;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
}
.waveform-wrap.paused .waveform-paused-label {
opacity: 1;
}
</style>

@@ -426,3 +407,2 @@ </head>

<canvas id="waveform-canvas" width="228" height="88"></canvas>
<span class="waveform-paused-label">paused</span>
</div>

@@ -429,0 +409,0 @@

+41
-19

@@ -129,5 +129,4 @@ // ---- Conversion utilities ----

if (waveform[i] === null) { started = false; continue; }
const db = rmsToDb(waveform[i]);
const x = i * colW;
const y = dbToY(db);
const y = dbToY(rmsToDb(waveform[i]));
if (!started) {

@@ -141,6 +140,8 @@ ctx.moveTo(x, CH);

}
ctx.lineTo((N - 1) * colW, CH);
ctx.closePath();
ctx.fillStyle = C_WAVEFORM;
ctx.fill();
if (started) {
ctx.lineTo((N - 1) * colW, CH);
ctx.closePath();
ctx.fillStyle = C_WAVEFORM;
ctx.fill();
}

@@ -152,5 +153,4 @@ // Waveform top line

if (waveform[i] === null) { started = false; continue; }
const db = rmsToDb(waveform[i]);
const x = i * colW + colW / 2;
const y = dbToY(db);
const y = dbToY(rmsToDb(waveform[i]));
if (!started) { ctx.moveTo(x, y); started = true; }

@@ -220,5 +220,19 @@ else ctx.lineTo(x, y);

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_DEBOUNCE_MS = 500; // Debounce polling when inactive
function pollState() {
browser.tabs.query({ active: true, currentWindow: true }).then(tabs => {
if (!tabs[0]) return;
const now = Date.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 => {

@@ -236,4 +250,2 @@ if (!state) return;

waveformWrap.classList.toggle("paused", !!state.paused);
if (!state.enabled) {

@@ -262,16 +274,26 @@ statusDot.className = "status-dot off";

}).catch(err => {
console.log("[YT Levelr popup] sendMessage failed:", err);
statusDot.className = "status-dot off";
statusText.textContent = "not on a YouTube video";
confidenceBar.className = "confidence-bar-fill";
confidenceBar.style.width = "0%";
confidencePct.textContent = "—";
waveformWrap.classList.remove("paused");
drawWaveform({ waveform: new Array(100).fill(null) });
// Silently handle message errors - likely not on YouTube or tab closed
if (err.message && err.message.includes("Could not establish connection")) {
// Tab closed or not on YouTube - stop polling
console.log("[YT Levelr popup] Not on YouTube video, stopping poll");
if (pollInterval) clearInterval(pollInterval);
pollInterval = null;
} else {
console.log("[YT Levelr popup] sendMessage failed:", err);
statusDot.className = "status-dot off";
statusText.textContent = "not on a YouTube video";
confidenceBar.className = "confidence-bar-fill";
confidenceBar.style.width = "0%";
confidencePct.textContent = "—";
drawWaveform({ waveform: new Array(100).fill(null) });
}
});
}).catch(err => {
console.warn("[YT Levelr popup] tabs.query failed:", err);
});
}
// Start polling after initial load
pollState();
setInterval(pollState, 800);
pollInterval = setInterval(pollState, POLL_INTERVAL_MS);

@@ -278,0 +300,0 @@ // ---- Controls ----

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