+132
-81
@@ -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; | ||
| } | ||
| }); |
+1
-1
| { | ||
| "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", |
+0
-20
@@ -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