YouTubeの音量を基準値(-14 LUFS)に統一し、ISO 226:2023準拠のラウドネス補正を行います。
// ==UserScript==
// @name YouTube Volume Normalizer
// @namespace http://tampermonkey.net/
// @version 6.6
// @description YouTubeの音量を基準値(-14 LUFS)に統一し、ISO 226:2023準拠のラウドネス補正を行います。
// @author むらひと
// @match https://www.youtube.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @grant unsafeWindow
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-start
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const win = unsafeWindow || window;
const TARGET_KEY = 'yt-player-stable-volume';
// ==========================================
// ■ Force OFF Interceptor (Storage Hook)
// ==========================================
function hookStorage(storage) {
if (!storage) return;
const originalSetItem = storage.setItem;
storage.setItem = function(key, value) {
if (key === TARGET_KEY) {
try {
if (typeof value === 'string' && !value.includes('"data":false')) {
const parsed = JSON.parse(value);
if (parsed && (parsed.data === true || parsed.data === 'true')) {
parsed.data = false;
parsed.creation = Date.now();
parsed.expiration = Date.now() + 2592000000;
value = JSON.stringify(parsed);
}
}
} catch (e) {}
}
return originalSetItem.apply(this, arguments);
};
}
try {
hookStorage(win.localStorage);
hookStorage(win.sessionStorage);
} catch(e) {}
function enforceNativeOffNow() {
const forceOffData = JSON.stringify({
data: false,
creation: Date.now(),
expiration: Date.now() + 2592000000
});
try {
if (win.localStorage) win.localStorage.setItem(TARGET_KEY, forceOffData);
if (win.sessionStorage) win.sessionStorage.setItem(TARGET_KEY, forceOffData);
} catch(e) {}
}
enforceNativeOffNow();
// ==========================================
// ■ Core Injection (CORS Enforcer)
// ==========================================
const originalSetAttribute = Element.prototype.setAttribute;
Element.prototype.setAttribute = function(name, value) {
if (this.tagName === 'VIDEO' && name === 'src') {
if (this.getAttribute('crossorigin') !== 'anonymous') {
originalSetAttribute.call(this, 'crossorigin', 'anonymous');
}
if (this.crossOrigin !== 'anonymous') {
this.crossOrigin = 'anonymous';
}
}
return originalSetAttribute.call(this, name, value);
};
const originalCreateElement = document.createElement;
document.createElement = function(tagName) {
const element = originalCreateElement.apply(this, arguments);
if (tagName && tagName.toLowerCase() === 'video') {
originalSetAttribute.call(element, 'crossorigin', 'anonymous');
element.crossOrigin = 'anonymous';
}
return element;
};
// ==========================================
// ■ Network Sniffer (Metadata) - Lock & Stable
// ==========================================
const loudnessDB = new Map();
const metaDB = new Map();
const MAX_CACHE_SIZE = 500;
let scanLock = false;
let currentSniffId = null;
function cacheLoudness(videoId, loudnessDb, isMusic = false, isLive = false) {
if (!videoId) return;
if (loudnessDB.size >= MAX_CACHE_SIZE) {
const firstKey = loudnessDB.keys().next().value;
loudnessDB.delete(firstKey);
}
if (metaDB.size >= MAX_CACHE_SIZE) {
const firstKey = metaDB.keys().next().value;
metaDB.delete(firstKey);
}
let val = Number(loudnessDb);
if (!isNaN(val)) {
if (loudnessDB.get(videoId) !== val) loudnessDB.set(videoId, val);
if (videoId === currentSniffId) {
scanLock = true;
}
}
if (metaDB.has(videoId)) {
const current = metaDB.get(videoId);
if (isMusic) current.isMusic = true;
if (isLive) current.isLive = true;
metaDB.set(videoId, current);
} else {
metaDB.set(videoId, { isMusic: isMusic, isLive: isLive });
}
}
function scanDeep(obj, depth = 0) {
if (scanLock) return;
if (!obj || typeof obj !== 'object' || depth > 10) return;
if (obj.playerConfig && obj.videoDetails) {
const vId = obj.videoDetails.videoId;
const lDb = obj.playerConfig.audioConfig?.loudnessDb;
let isMusic = false;
if (obj.videoDetails.musicVideoType && obj.videoDetails.musicVideoType !== 'MUSIC_VIDEO_TYPE_UV_EMPTY') isMusic = true;
if (obj.microformat?.playerMicroformatRenderer?.category === 'Music' || obj.microformat?.playerMicroformatRenderer?.category === '音楽') isMusic = true;
let isLive = false;
if (obj.videoDetails.isLive === true) isLive = true;
if (obj.microformat?.playerMicroformatRenderer?.isLiveBroadcast === true) isLive = true;
if (vId && (lDb !== undefined || isLive || isMusic)) {
cacheLoudness(vId, lDb, isMusic, isLive);
}
}
if (obj.videoDetails && obj.videoDetails.videoId) {
const vId = obj.videoDetails.videoId;
const lDb = obj.playerConfig?.audioConfig?.loudnessDb;
let isMusic = false;
if (obj.videoDetails.musicVideoType && obj.videoDetails.musicVideoType !== 'MUSIC_VIDEO_TYPE_UV_EMPTY') isMusic = true;
if (obj.microformat?.playerMicroformatRenderer?.category === 'Music' || obj.microformat?.playerMicroformatRenderer?.category === '音楽') isMusic = true;
let isLive = false;
if (obj.videoDetails.isLive === true) isLive = true;
if (obj.microformat?.playerMicroformatRenderer?.isLiveBroadcast === true) isLive = true;
if (lDb !== undefined || isLive || isMusic) {
cacheLoudness(vId, lDb, isMusic, isLive);
}
return;
}
if (Array.isArray(obj)) {
const len = obj.length;
for (let i = 0; i < len; i++) {
scanDeep(obj[i], depth + 1);
}
return;
}
const skipKeys = new Set([
'formats', 'adaptiveFormats', 'dashManifest', 'hlsManifest',
'storyboard', 'trackingParams', 'adPlacements', 'attestation',
'thumbnails', 'responseContext', 'actions', 'frameworkUpdates',
'conversationBar', 'emojiPicker', 'searchEndpoint', 'onResponseReceivedEndpoints',
'cards', 'annotations', 'captionTracks',
'liveChatRenderer'
]);
for (const key in obj) {
if (skipKeys.has(key)) continue;
if (key === 'contents' && depth > 2) continue;
scanDeep(obj[key], depth + 1);
}
}
// --- NETWORK INTERCEPTOR START ---
let _ytInitialPlayerResponse = win.ytInitialPlayerResponse;
Object.defineProperty(win, 'ytInitialPlayerResponse', {
get: () => _ytInitialPlayerResponse,
set: (val) => { _ytInitialPlayerResponse = val; setTimeout(() => scanDeep(val), 0); },
configurable: true, enumerable: true
});
const originalFetch = win.fetch;
win.fetch = function(input, init) {
if (scanLock) return originalFetch.apply(this, arguments);
const promise = originalFetch.apply(this, arguments);
promise.then(async (response) => {
if (scanLock) return;
const url = (typeof input === 'string' ? input : (input instanceof Request ? input.url : ''));
if (url && (
url.includes('/youtubei/v1/player') ||
url.includes('/youtubei/v1/next') ||
url.includes('/youtubei/v1/reel')
)) {
try {
const clone = response.clone();
const data = await clone.json();
scanDeep(data);
} catch(e) {}
}
}).catch(() => {});
return promise;
};
const originalXhrOpen = win.XMLHttpRequest.prototype.open;
win.XMLHttpRequest.prototype.open = function(method, url) {
this._monitor_url = url;
return originalXhrOpen.apply(this, arguments);
};
const originalXhrSend = win.XMLHttpRequest.prototype.send;
win.XMLHttpRequest.prototype.send = function(body) {
if (scanLock) return originalXhrSend.apply(this, arguments);
const isTarget = typeof this._monitor_url === 'string' && (
this._monitor_url.includes('/youtubei/v1/player') ||
this._monitor_url.includes('/youtubei/v1/next') ||
this._monitor_url.includes('/youtubei/v1/reel')
);
if (isTarget) {
this.addEventListener('load', () => {
if (scanLock) return;
try {
const data = JSON.parse(this.responseText);
scanDeep(data);
} catch(e) {}
}, { once: true });
}
return originalXhrSend.apply(this, arguments);
};
setTimeout(() => {
if (win.ytInitialPlayerResponse) scanDeep(win.ytInitialPlayerResponse);
if (win.ytInitialData) scanDeep(win.ytInitialData);
}, 500);
// ==========================================
// ■ Observer & Play Hook
// ==========================================
let lastPlayTime = 0;
const hookedVideos = new WeakMap();
const sourceCache = new WeakMap(); // ★FIX: MediaElementSource Cache
function ensureCrossorigin(video) {
if (video.getAttribute('crossorigin') !== 'anonymous') {
originalSetAttribute.call(video, 'crossorigin', 'anonymous');
}
if (video.crossOrigin !== 'anonymous') {
video.crossOrigin = 'anonymous';
}
if (!hookedVideos.has(video)) {
hookedVideos.set(video, true);
video._norm_hooked = true;
video.addEventListener('playing', onVideoPlaying, { capture: true });
video.addEventListener('play', onVideoPlay, { capture: true });
}
}
function onVideoPlay(e) {
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
const v = e.target;
lastPlayTime = Date.now();
if (v.crossOrigin !== 'anonymous') {
originalSetAttribute.call(v, 'crossorigin', 'anonymous');
v.crossOrigin = 'anonymous';
}
}
function onVideoPlaying(e) {
const v = e.target;
if (v !== currentTargetVideo || !nodes.processor) {
const active = findTargetVideo();
if (active === v) {
initAudio(v);
}
}
}
function seekToLiveEdge(v) {
try {
if (v.readyState < 3) return false;
const player = win.document.getElementById('movie_player');
if (player && player.seekToStreamTime && player.getDuration) {
const d = player.getDuration();
if (isFinite(d) && d > 0) {
if (Math.abs(d - v.currentTime) < 3.0) return true;
player.seekTo(d, true);
return true;
}
}
if (v.duration === Infinity && v.buffered && v.buffered.length > 0) {
const end = v.buffered.end(v.buffered.length - 1);
if (Math.abs(v.currentTime - end) > 10) {
v.currentTime = end;
return true;
}
}
} catch(e) {}
return false;
}
function safeReload(v) {
try {
const player = win.document.getElementById('movie_player');
if (player && player.loadVideoById && player.getVideoData) {
const data = player.getVideoData();
if (data && data.video_id) {
player.loadVideoById(data.video_id, v.currentTime);
return true;
}
}
} catch(e) {}
return false;
}
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'childList') {
for (const node of m.addedNodes) {
if (node.nodeName === 'VIDEO') ensureCrossorigin(node);
else if (node.querySelectorAll) {
const videos = node.querySelectorAll('video');
videos.forEach(ensureCrossorigin);
}
}
}
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
// ==========================================
// 1. Config & State
// ==========================================
const CONFIG = {
TARGET_LUFS: -14.0,
TARGET_LUFS_MUSIC: -14.0,
AGC_OFFSET: 0.0,
WINDOW_SECONDS_MOMENTARY: 0.4,
WINDOW_SECONDS_SHORT: 3.0,
WINDOW_SECONDS_SHORT_MUSIC: 5.0,
WINDOW_SECONDS_PSEUDO_STABLE: 10.0,
WINDOW_SECONDS_INTEGRATED: 10.0,
MAX_BOOST: 15.0,
ATTACK_SPEED: 0.3,
RELEASE_SPEED: 0.01,
LOW_CUT_FREQ: 20,
DYN_EQ_SUBBASS_FREQ: 35,
DYN_EQ_MAX_SUBBASS: 9.0,
DYN_EQ_BASS_FREQ: 85,
DYN_EQ_MAX_BASS: 10.0,
DYN_EQ_MIDBASS_FREQ: 250,
DYN_EQ_MIDBASS_Q: 0.5,
DYN_EQ_MAX_MIDBASS: 3.5,
DYN_EQ_TREBLE_FREQ: 11000,
DYN_EQ_MAX_TREBLE: 2.5,
DYN_EQ_THRESHOLD: -14.0,
VOL_SLIDER_DYNAMIC_RANGE: 40.0,
SILENCE_LIMIT_BLOCKS: 60,
SILENCE_LIMIT_BLOCKS_LIVE: 200,
ABS_SILENCE_THRESHOLD: -70.0,
REL_GATE_THRESHOLD: -10.0,
STATS_UPDATE_INTERVAL: 100,
RELOAD_COOLDOWN: 10000,
MAX_RECOVERY_ATTEMPTS: 3,
AGC_HOLD_TIME: 60000,
RECOVERY_THRESHOLD: 60000,
SILENCE_THRESHOLD: -70.0,
UI_UPDATE_INTERVAL: 100,
};
// ★ Pre-calculated Constants
const ABS_THRESH_LINEAR = Math.pow(10, (CONFIG.ABS_SILENCE_THRESHOLD + 0.691) / 10.0);
const LOG10_INV = 1 / Math.log(10);
const DECIMATION_STEP = 4;
const UI = {
COLORS: {
STATIC: '#4caf50', AGC: '#00bcd4', WAIT: '#9e9e9e',
FIX: '#9c27b0', SAFE: '#ff9800', ERR: '#f44336', INIT: '#ffeb3b',
NATIVE: '#ff9800', FAIL: '#607d8b'
}
};
let audioCtx;
let nodes = {
source: null, gain: null, limiter: null, processor: null, kShelf: null, kHighPass: null, muteGain: null,
dynSub: null, dynLow: null, dynMidLow: null, dynHigh: null
};
let currentTargetVideo = null;
let learnedNativeState = GM_getValue('learnedNativeState', null);
let state = {
lastVideoSrc: '',
currentMomentaryLUFS: -100,
currentShortLUFS: -100,
currentIntegLUFS: -100,
currentGain: 1.0,
animationId: null,
forceIgnoreNative: GM_getValue('forceIgnoreNative', true),
forceAGC: GM_getValue('forceAGC', false),
usePseudoStable: GM_getValue('usePseudoStable', false),
useDynamicEQ: GM_getValue('useDynamicEQ', false),
dynEqRefVolume: GM_getValue('dynEqRefVolume', 1.0),
lastCheckTime: 0,
lastVideoTime: 0,
playingSilenceCount: 0,
isRecovering: false,
recoveryAttempts: 0,
lastReloadTime: 0,
continuousSilenceStart: 0,
lastValidLUFS: -100,
isBypassed: false,
bypassReason: '',
lastStatsTime: 0,
cachedStats: { diff: null, isMusic: false, isLive: false, isNorm: false },
stickyStats: null,
nativeStateInfo: { isOn: true, source: 'Init' },
isConfirmedLive: false,
currentUrl: location.href,
zeroDataCount: 0,
lastUiUpdate: 0,
lastStatusColor: '',
isNavigating: false,
lastNativeEnforceTime: 0,
isMenuOpen: false
};
let dsp = {
history: null,
historySize: 0,
cursor: 0,
bufferSize: 4096,
momBlocks: 0,
shortBlocks: 0,
shortBlocksMusic: 0,
pseudoBlocks: 0,
integBlocks: 0
};
let uiElement = null;
let tooltipNode = null;
let menuNode = null;
// ==========================================
// 2. Logic & Detection
// ==========================================
function safeLog10(val) {
return (val > 1e-12) ? Math.log(val) * LOG10_INV : -12.0;
}
function tryParse(str) {
try { return JSON.parse(str); } catch(e) { return null; }
}
function scrapeSettingsMenu() {
const menuItems = document.querySelectorAll('.ytp-menuitem');
if (!menuItems || menuItems.length === 0) return;
menuItems.forEach(item => {
const label = item.querySelector('.ytp-menuitem-label');
if (!label) return;
const text = label.textContent || "";
if (text.includes('一定音量') || text.toLowerCase().includes('stable volume')) {
const isChecked = item.getAttribute('aria-checked') === 'true';
if (learnedNativeState !== isChecked) {
learnedNativeState = isChecked;
GM_setValue('learnedNativeState', isChecked);
}
}
});
}
function checkNativeStableVolume(isMusicVideo) {
if (state.forceIgnoreNative) return { isOn: false, source: '強制OFF(Script)' };
if (isMusicVideo) return { isOn: false, source: '音楽動画' };
if (learnedNativeState !== null) return { isOn: learnedNativeState, source: '学習済み' };
try {
const storages = [
{ name: 'LocalStorage', store: win.localStorage },
{ name: 'WinStorage', store: window.localStorage },
{ name: 'Session', store: win.sessionStorage },
{ name: 'WinSession', store: window.sessionStorage }
];
for (const s of storages) {
if (!s.store) continue;
const raw = s.store.getItem(TARGET_KEY);
if (raw) {
const parsed = tryParse(raw);
if (parsed) {
if (parsed.data === true || parsed.data === 'true') return { isOn: true, source: s.name };
if (parsed.data === false || parsed.data === 'false') return { isOn: false, source: s.name };
}
}
}
} catch (e) {}
return { isOn: true, source: 'デフォルト' };
}
document.addEventListener('click', (e) => {
if (e.target.closest('.ytp-settings-button') || e.target.closest('.ytp-menuitem')) {
setTimeout(scrapeSettingsMenu, 200);
setTimeout(scrapeSettingsMenu, 500);
}
if (state.isMenuOpen && menuNode && !menuNode.contains(e.target) && uiElement && !uiElement.contains(e.target)) {
closeMenu();
}
}, { capture: true });
function findTargetVideo() {
if (location.pathname.includes('/shorts/')) {
const activeShort = document.querySelector('ytd-reel-video-renderer[is-active] video');
if (activeShort) return activeShort;
}
const playerVideo = document.querySelector('#movie_player video');
if (playerVideo) {
if (playerVideo.readyState > 0 || playerVideo.src) return playerVideo;
}
if (isWatchPage()) {
const videos = document.getElementsByTagName('video');
if (videos.length > 0) {
for (let i = 0; i < videos.length; i++) {
if (videos[i].readyState > 0) return videos[i];
}
return videos[0];
}
}
return null;
}
function isWatchPage() {
return location.pathname.includes('/watch') || location.pathname.includes('/live') || location.pathname.includes('/shorts/') || location.pathname.includes('/embed/');
}
// ==========================================
// 3. Stats
// ==========================================
function checkLiveRobustly(targetId) {
if (loudnessDB.has(targetId)) return false;
const flexy = document.querySelector('ytd-watch-flexy');
if (flexy && flexy.hasAttribute('is-live')) return true;
try {
const player = unsafeWindow.document.getElementById('movie_player');
if (player && player.getVideoData) {
const data = player.getVideoData();
if (data && data.video_id === targetId && data.isLive) return true;
}
} catch(e) {}
const v = findTargetVideo();
if (v && v.duration === Infinity) return true;
return !!document.querySelector('.html5-video-player .ytp-live-badge:not([disabled])');
}
function getYouTubeStats() {
const now = Date.now();
const updateInterval = location.pathname.includes('/shorts/') ? 50 : CONFIG.STATS_UPDATE_INTERVAL;
if (state.cachedStats.diff !== null && now - state.lastStatsTime < updateInterval) {}
let targetId = null;
if (location.pathname.includes('/shorts/')) {
const activeReel = document.querySelector('ytd-reel-video-renderer[is-active]');
if (activeReel && activeReel.getAttribute('data-video-id')) {
targetId = activeReel.getAttribute('data-video-id');
} else {
targetId = location.pathname.split('/shorts/')[1]?.split('?')[0];
}
} else {
const params = new URLSearchParams(window.location.search);
targetId = params.get('v');
}
if (!targetId) {
try {
const player = win.document.getElementById('movie_player');
if (player && player.getVideoData) {
const data = player.getVideoData();
if (data && data.video_id) targetId = data.video_id;
}
} catch(e){}
}
if (targetId && targetId !== currentSniffId) {
currentSniffId = targetId;
}
if (targetId) {
if (loudnessDB.has(targetId) || metaDB.has(targetId)) {
const meta = metaDB.get(targetId) || {};
const stats = {
diff: loudnessDB.has(targetId) ? loudnessDB.get(targetId) : null,
isMusic: meta.isMusic || false,
isLive: meta.isLive || false,
isNorm: true
};
if (stats.diff !== null) state.stickyStats = stats;
state.cachedStats = stats;
state.lastStatsTime = now;
return state.cachedStats;
}
if (state.stickyStats) {
state.cachedStats = state.stickyStats;
state.lastStatsTime = now;
return state.stickyStats;
}
}
state.cachedStats = { diff: null, isMusic: false, isLive: false, isNorm: false };
state.lastStatsTime = now;
return state.cachedStats;
}
// ==========================================
// 4. UI (Full Set)
// ==========================================
function createTooltip() {
if (document.getElementById('yt-norm-tooltip')) {
tooltipNode = document.getElementById('yt-norm-tooltip');
return;
}
tooltipNode = document.createElement('div');
tooltipNode.id = 'yt-norm-tooltip';
tooltipNode.style.cssText = `
position: fixed; z-index: 9999; background-color: rgba(20, 20, 20, 0.95);
color: #f1f1f1; padding: 10px 14px; border-radius: 6px;
font-family: "Roboto", "Arial", sans-serif; font-size: 12px;
line-height: 1.6; white-space: pre-wrap; pointer-events: none;
display: none; opacity: 0; transition: opacity 0.1s ease-in-out;
box-shadow: 0 4px 15px rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.1);
text-align: left;
`;
document.body.appendChild(tooltipNode);
}
function createSettingsMenu() {
if (document.getElementById('yt-norm-menu')) {
menuNode = document.getElementById('yt-norm-menu');
return;
}
menuNode = document.createElement('div');
menuNode.id = 'yt-norm-menu';
menuNode.style.cssText = `
position: fixed; z-index: 10000; background-color: #1f1f1f;
color: #eee; padding: 12px; border-radius: 8px;
font-family: "Roboto", "Arial", sans-serif; font-size: 13px;
display: none; box-shadow: 0 4px 16px rgba(0,0,0,0.6);
border: 1px solid rgba(255,255,255,0.15); min-width: 250px; user-select: none;
`;
const title = document.createElement('div');
title.textContent = 'Normalizer Settings';
title.style.cssText = 'font-weight:bold; margin-bottom:8px; border-bottom:1px solid #444; padding-bottom:4px; color:#aaa; font-size:11px; text-transform:uppercase;';
const createRow = (text, checked, callback, titleAttr) => {
const row = document.createElement('div');
row.style.cssText = 'display:flex; align-items:center; margin-bottom:10px; cursor:pointer;';
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.checked = checked;
cb.style.cssText = 'margin-right:8px; transform:scale(1.2); cursor:pointer;';
const lb = document.createElement('label');
lb.textContent = text; lb.style.cursor = 'pointer';
if(titleAttr) lb.title = titleAttr;
row.appendChild(cb); row.appendChild(lb);
row.onclick = (e) => { if(e.target !== cb) cb.checked = !cb.checked; callback(cb.checked); };
return row;
};
menuNode.appendChild(title);
menuNode.appendChild(createRow('YouTube「一定音量」を強制OFF', state.forceIgnoreNative, (val) => {
state.forceIgnoreNative = val; GM_setValue('forceIgnoreNative', val);
if(val) enforceNativeOffNow();
updateIndicator(state.lastStatusColor, state.currentGain, -100, CONFIG.TARGET_LUFS, checkNativeStableVolume(false));
}));
const dynRow = createRow('等ラウドネス補正 (Dynamic EQ)', state.useDynamicEQ, (val) => {
state.useDynamicEQ = val; GM_setValue('useDynamicEQ', val);
const calib = document.getElementById('yt-norm-calib');
if(calib) calib.style.display = val ? 'block' : 'none';
}, "ISO 226:2023準拠。聴感を補正します。");
menuNode.appendChild(dynRow);
const calibContainer = document.createElement('div');
calibContainer.id = 'yt-norm-calib';
calibContainer.style.cssText = `margin-left: 24px; margin-bottom: 10px; display: ${state.useDynamicEQ ? 'block' : 'none'};`;
const calibBtn = document.createElement('button');
calibBtn.innerHTML = '🎧 現在の音量を基準(Flat)にする';
calibBtn.style.cssText = `background: #006064; color: #e0f7fa; border: 1px solid #00838f; border-radius: 4px; padding: 4px 8px; font-size: 11px; cursor: pointer; width: 100%; transition: background 0.2s;`;
calibBtn.onclick = (e) => {
e.stopPropagation();
const v = currentTargetVideo;
if(v) {
let vol = v.volume; if(vol < 0.01) vol = 0.01;
state.dynEqRefVolume = vol; GM_setValue('dynEqRefVolume', vol);
calibBtn.textContent = `基準設定完了 (Ref: ${(vol*100).toFixed(0)}%)`;
setTimeout(() => calibBtn.textContent = '🎧 現在の音量を基準(Flat)にする', 2000);
}
};
calibContainer.appendChild(calibBtn);
menuNode.appendChild(calibContainer);
menuNode.appendChild(createRow('常にAGCモードを使用', state.forceAGC, (val) => {
state.forceAGC = val; GM_setValue('forceAGC', val);
updateIndicator(state.lastStatusColor, state.currentGain, -100, CONFIG.TARGET_LUFS, checkNativeStableVolume(false));
}, "メタデータを使用せずリアルタイム調整"));
menuNode.appendChild(createRow('AGC判定を安定化 (疑似Stable)', state.usePseudoStable, (val) => {
state.usePseudoStable = val; GM_setValue('usePseudoStable', val);
updateIndicator(state.lastStatusColor, state.currentGain, -100, CONFIG.TARGET_LUFS, checkNativeStableVolume(false));
}, "Short-term判定時間を延長し変動を抑制"));
const resetBtn = document.createElement('button');
resetBtn.textContent = '音声再初期化 (リセット)';
resetBtn.style.cssText = `width: 100%; padding: 6px; background: #333; color: #fff; border: 1px solid #555; border-radius: 4px; cursor: pointer; font-size: 11px; margin-top: 4px;`;
resetBtn.onclick = () => { closeMenu(); fullReset(); initAudio(); };
menuNode.appendChild(resetBtn);
document.body.appendChild(menuNode);
}
function toggleSettingsMenu(e) {
if (!menuNode) createSettingsMenu();
if (state.isMenuOpen) closeMenu(); else openMenu(e);
}
function openMenu(e) {
if (!menuNode || !uiElement) return;
state.isMenuOpen = true; hideTooltip();
const rect = uiElement.getBoundingClientRect();
menuNode.style.top = (rect.bottom + 8) + 'px';
let left = rect.left;
if (left + 240 > window.innerWidth) left = window.innerWidth - 240;
menuNode.style.left = left + 'px';
menuNode.style.display = 'block';
}
function closeMenu() {
if (menuNode) menuNode.style.display = 'none';
state.isMenuOpen = false;
}
function showTooltip() {
if (state.isMenuOpen || !tooltipNode || !uiElement) return;
const rect = uiElement.getBoundingClientRect();
tooltipNode.style.top = (rect.bottom + 12) + 'px';
let left = rect.left;
if (left + 250 > window.innerWidth) left = window.innerWidth - 260;
tooltipNode.style.left = left + 'px';
tooltipNode.style.display = 'block';
requestAnimationFrame(() => { if (tooltipNode) tooltipNode.style.opacity = '1'; });
}
function hideTooltip() {
if (!tooltipNode) return;
tooltipNode.style.opacity = '0';
setTimeout(() => { if (tooltipNode && tooltipNode.style.opacity === '0') tooltipNode.style.display = 'none'; }, 100);
}
function initIndicator() {
if (document.getElementById('yt-norm-btn')) {
uiElement = document.getElementById('yt-norm-btn');
if (!document.getElementById('yt-norm-tooltip')) createTooltip();
if (!document.getElementById('yt-norm-menu')) createSettingsMenu();
updateVisibility();
return;
}
const buttonsContainer = document.querySelector('ytd-masthead #end #buttons');
if (!buttonsContainer) { setTimeout(initIndicator, 1000); return; }
const el = document.createElement('div');
el.id = 'yt-norm-btn';
el.className = 'style-scope ytd-masthead';
el.style.cssText = `display: inline-flex; align-items: center; justify-content: center; position: relative; cursor: pointer; width: 40px; height: 40px; margin-right: 8px; opacity: 0.9; color: var(--yt-spec-text-primary);`;
const svg = `<svg viewBox="0 0 24 24" preserveAspectRatio="xMidYMid meet" focusable="false" style="pointer-events: none; display: block; width: 24px; height: 24px; fill: currentColor;"><path d="M14,3.23V5.29C16.89,6.15 19,8.83 19,12C19,15.17 16.89,17.84 14,18.7V20.77C18,19.86 21,16.28 21,12C21,7.72 18,4.14 14,3.23M16.5,12C16.5,10.23 15.48,8.71 14,7.97V16.02C15.48,15.29 16.5,13.77 16.5,12M3,9V15H7L12,20V4L7,9H3Z"></path></svg>`;
const statusBar = `<div id="yt-norm-status-bar" style="position: absolute; bottom: 6px; left: 20%; width: 60%; height: 3px; background-color: #888; border-radius: 2px; transition: background-color 0.3s;"></div>`;
el.innerHTML = svg + statusBar;
const notificationBtn = buttonsContainer.querySelector('ytd-notification-topbar-button-renderer');
if (notificationBtn) buttonsContainer.insertBefore(el, notificationBtn); else buttonsContainer.appendChild(el);
uiElement = el;
createTooltip(); createSettingsMenu();
uiElement.addEventListener('mouseenter', showTooltip);
uiElement.addEventListener('mouseleave', hideTooltip);
uiElement.addEventListener('click', (e) => { e.stopPropagation(); e.preventDefault(); toggleSettingsMenu(e); });
updateVisibility();
}
function updateVisibility() {
if (!uiElement) return;
const isActive = isWatchPage() || (currentTargetVideo && !currentTargetVideo.paused && currentTargetVideo.readyState > 0);
uiElement.style.display = isActive ? 'inline-flex' : 'none';
}
function updateIndicator(status, gain, inputLUFS, effectiveTarget, nativeInfo) {
let color = UI.COLORS.INIT;
let titleHead = '初期化中';
switch (status) {
case 'static': color = UI.COLORS.STATIC; titleHead = `[Stable] メタデータ適用中`; break;
case 'agc': case 'agc-live': case 'agc-hold': case 'waiting-silence': color = UI.COLORS.AGC; titleHead = `[AGC] 自動調整 (Auto)`; break;
case 'waiting-loading': color = UI.COLORS.WAIT; titleHead = `[待機] 読み込み中`; break;
case 'native-bypass-setting': case 'native-bypass-loud': color = UI.COLORS.NATIVE; titleHead = `[バイパス] YouTube機能で制御`; break;
case 'recovering': color = UI.COLORS.FIX; titleHead = `[修復中] ストリーム再接続...`; break;
case 'bypassed': color = UI.COLORS.SAFE; titleHead = `[安全装置] 停止中`; break;
case 'failed': color = UI.COLORS.FAIL; titleHead = `[機能停止] 音声取得エラー`; break;
case 'error': color = UI.COLORS.ERR; titleHead = 'エラー発生'; break;
case 'init': color = UI.COLORS.INIT; titleHead = `[検索中] 動画を探索しています`; break;
}
const now = Date.now();
if (state.lastStatusColor !== color) { /* changed */ }
else if (now - state.lastUiUpdate < CONFIG.UI_UPDATE_INTERVAL) { if (!tooltipNode || tooltipNode.style.display === 'none') return; }
state.lastUiUpdate = now; state.lastStatusColor = color;
if (!uiElement || !document.body.contains(uiElement)) initIndicator();
updateVisibility();
if (!uiElement || uiElement.style.display === 'none') return;
const gainDb = (gain > 0.0001) ? 20 * safeLog10(gain) : 0;
const gainText = `${gainDb >= 0 ? '+' : ''}${gainDb.toFixed(1)} dB`;
let lufsDisplay = (inputLUFS > -100) ? inputLUFS.toFixed(1) : '-Inf';
if ((status === 'waiting-silence' || status === 'agc-hold') && state.lastValidLUFS > -100) lufsDisplay = `${state.lastValidLUFS.toFixed(1)} (Wait)`;
const bar = document.getElementById('yt-norm-status-bar');
if (bar && bar.style.backgroundColor !== color) bar.style.backgroundColor = color;
if (tooltipNode) {
let dynEqText = "";
if (state.useDynamicEQ && nodes.dynLow) {
const b = nodes.dynLow.gain.value;
if (b > 0.2) dynEqText = ` [DynEQ: +${b.toFixed(1)}dB]`;
}
// ラベルの出し分けロジック
let inputLabel = "現在値";
if (status === 'static' || status.startsWith('native')) {
inputLabel = "元音量 (Meta)";
} else if (status.startsWith('agc')) {
inputLabel = "計測値 (Real-time)";
}
tooltipNode.innerText = `${titleHead}\n----------------\n${inputLabel}: ${lufsDisplay} LUFS\n補正: ${gainText}${dynEqText}\n目標: ${effectiveTarget.toFixed(1)} LUFS\n\n[クリックして設定]`;
}
}
// ==========================================
// 5. Processing & Recovery
// ==========================================
function onNavigateStart() {
console.log('[Normalizer] Navigate Start -> Cleanup');
state.isNavigating = true; closeMenu();
}
function onNavigateFinish() {
state.isNavigating = false;
scanLock = false;
}
function fullReset() {
console.log('[Normalizer] Full Reset');
state.isNavigating = false;
currentTargetVideo = null;
state.lastVideoSrc = '';
state.currentMomentaryLUFS = -100;
state.currentShortLUFS = -100;
state.currentIntegLUFS = -100;
state.currentGain = 1.0;
state.zeroDataCount = 0;
state.isRecovering = false;
state.recoveryAttempts = 0;
state.lastReloadTime = 0;
state.continuousSilenceStart = 0;
state.lastValidLUFS = -100;
state.isBypassed = false;
state.stickyStats = null;
scanLock = false;
currentSniffId = null;
cleanupAudioNodes();
if (state.animationId) { cancelAnimationFrame(state.animationId); state.animationId = null; }
updateVisibility();
}
document.addEventListener('yt-navigate-start', onNavigateStart);
document.addEventListener('yt-navigate-finish', onNavigateFinish);
function enableBypassMode(reason = '') {
if (state.isBypassed) return;
state.isBypassed = true; state.isRecovering = false; state.bypassReason = reason;
cleanupAudioNodes();
updateIndicator('failed', 1.0, -100, CONFIG.TARGET_LUFS, state.nativeStateInfo);
}
async function performRecovery(isLive) {
if (Date.now() - state.lastReloadTime < CONFIG.RELOAD_COOLDOWN) return;
const v = currentTargetVideo;
if (!v) return;
// 回復機能がループするのを防ぐため、既にロード中(2)なら何もしない
// しかし、音声処理自体は(processAudioBlockで)止めない
if (v.networkState === 2 || v.seeking) {
state.zeroDataCount = 0;
return;
}
state.isRecovering = true;
state.recoveryAttempts++;
state.lastReloadTime = Date.now();
state.continuousSilenceStart = 0;
if (state.recoveryAttempts > CONFIG.MAX_RECOVERY_ATTEMPTS) {
enableBypassMode('Max Retry Reached');
return;
}
console.log(`[Normalizer] Recovery Attempt (${state.recoveryAttempts}) for ${isLive?'Live':'VOD'}...`);
if (audioCtx) { try { await audioCtx.close(); } catch(e){} audioCtx = null; }
cleanupAudioNodes();
// Cacheはクリアしない(DOM要素が同じなら再利用するため)
v.setAttribute('crossorigin', 'anonymous');
v.crossOrigin = 'anonymous';
let actionSuccess = isLive ? seekToLiveEdge(v) : safeReload(v);
if (!actionSuccess && v.src && !v.src.startsWith('blob:')) {
v.load();
setTimeout(() => v.play().catch(() => {}), 100);
}
const watchdog = setInterval(() => {
if (!state.isRecovering) { clearInterval(watchdog); return; }
if (v.readyState >= 3 && !v.paused && v.networkState !== 2) {
clearInterval(watchdog);
state.zeroDataCount = 0;
state.isRecovering = false;
initAudio();
}
}, 1000);
setTimeout(() => { if(state.isRecovering) { clearInterval(watchdog); state.isRecovering = false; initAudio(); } }, 8000);
}
function cleanupAudioNodes() {
// ★FIX: Source node is not nulled here, only disconnected. It is cached in WeakMap.
if (nodes.source) { try { nodes.source.disconnect(); } catch(e){} }
if (nodes.processor) { try { nodes.processor.disconnect(); nodes.processor.onaudioprocess = null; } catch(e){} nodes.processor = null; }
if (nodes.gain) { try { nodes.gain.disconnect(); } catch(e){} nodes.gain = null; }
if (nodes.limiter) { try { nodes.limiter.disconnect(); } catch(e){} nodes.limiter = null; }
if (nodes.kShelf) { try { nodes.kShelf.disconnect(); } catch(e){} nodes.kShelf = null; }
if (nodes.kHighPass) { try { nodes.kHighPass.disconnect(); } catch(e){} nodes.kHighPass = null; }
if (nodes.muteGain) { try { nodes.muteGain.disconnect(); } catch(e){} nodes.muteGain = null; }
if (nodes.dynSub) { try { nodes.dynSub.disconnect(); } catch(e){} nodes.dynSub = null; }
if (nodes.dynLow) { try { nodes.dynLow.disconnect(); } catch(e){} nodes.dynLow = null; }
if (nodes.dynMidLow) { try { nodes.dynMidLow.disconnect(); } catch(e){} nodes.dynMidLow = null; }
if (nodes.dynHigh) { try { nodes.dynHigh.disconnect(); } catch(e){} nodes.dynHigh = null; }
// Preserve source ref in the nodes object structure temporarily, initAudio will handle it.
const savedSource = nodes.source;
nodes = { source: savedSource, gain: null, limiter: null, processor: null, kShelf: null, kHighPass: null, muteGain: null, dynSub: null, dynLow: null, dynMidLow: null, dynHigh: null };
}
// ==========================================
// Core DSP Logic (BS.1770 Compliant) [Zero-Alloc & Decimated]
// ==========================================
function calculateLoudnessFromCircular(buffer, size, cursor, samplesToRead, useRelativeGate) {
if (samplesToRead === 0 || samplesToRead > size) return -100;
let sum = 0;
let count = 0;
let idx = cursor - 1;
for (let i = 0; i < samplesToRead; i++) {
if (idx < 0) idx = size - 1;
const val = buffer[idx];
if (val > ABS_THRESH_LINEAR) {
sum += val;
count++;
}
idx--;
}
if (count === 0) return -100;
if (!useRelativeGate) return -0.691 + 10 * safeLog10(sum / count);
const absoluteGatedPower = sum / count;
const absoluteGatedLoudness = -0.691 + 10 * safeLog10(absoluteGatedPower);
const relThresholdLUFS = absoluteGatedLoudness + CONFIG.REL_GATE_THRESHOLD;
const relThresholdLinear = Math.pow(10, (relThresholdLUFS + 0.691) / 10.0);
const finalThreshold = Math.max(ABS_THRESH_LINEAR, relThresholdLinear);
let gatedSum = 0;
let gatedCount = 0;
idx = cursor - 1;
for (let i = 0; i < samplesToRead; i++) {
if (idx < 0) idx = size - 1;
const val = buffer[idx];
if (val > finalThreshold) {
gatedSum += val;
gatedCount++;
}
idx--;
}
if (gatedCount === 0) return -100;
return -0.691 + 10 * safeLog10(gatedSum / gatedCount);
}
function processAudioBlock(inputBuffer) {
const v = currentTargetVideo;
// ★FIX: Ignore zero data during seeking, loading, or buffer shortage to prevent false silence detection
if (!v || v.seeking || v.readyState < 3) { state.zeroDataCount = 0; return; }
const ch0 = inputBuffer.getChannelData(0);
// Lightweight silence check (already decimated by 256)
if (ch0[0] === 0) {
let isSilence = true;
for (let i=0; i<ch0.length; i+=256) { if(ch0[i]!==0) { isSilence=false; break; } }
if (isSilence) {
if (v && !v.paused && v.readyState >= 3) {
// 読み込み中(2)なら、これは「正常なバッファ待ち」の可能性が高いのでカウントしない
if (v.networkState === 2) {
state.zeroDataCount = 0;
return;
}
// ライブとVODで許容する無音時間を分ける
const limit = (state.isConfirmedLive) ? CONFIG.SILENCE_LIMIT_BLOCKS_LIVE : CONFIG.SILENCE_LIMIT_BLOCKS;
if (!state.isRecovering && (Date.now() - lastPlayTime < 3000) && state.zeroDataCount > 40) {
if (state.recoveryAttempts < CONFIG.MAX_RECOVERY_ATTEMPTS) performRecovery(state.isConfirmedLive);
}
if (!state.isRecovering) state.zeroDataCount++;
if (state.zeroDataCount > limit) {
// バッファリングではない(ReadyState>=3 かつ NetworkState!=2)のに長時間無音なら
// CORS等の問題でデータが取れていないと判断して回復
performRecovery(state.isConfirmedLive);
}
}
return;
}
}
state.zeroDataCount = 0;
if (state.recoveryAttempts > 0) state.recoveryAttempts = 0;
// ★ Zero Alloc & Input Decimation
let sumSquares = 0;
const numChannels = inputBuffer.numberOfChannels;
const len = ch0.length;
for (let c = 0; c < numChannels; c++) {
const data = inputBuffer.getChannelData(c);
// ★ DECIMATION: Step by DECIMATION_STEP (e.g., 4) to skip samples
for (let i = 0; i < len; i += DECIMATION_STEP) {
const s = data[i];
sumSquares += s * s;
}
}
const totalSamplesProcessed = (len / DECIMATION_STEP) * numChannels;
const meanSquare = sumSquares / totalSamplesProcessed;
if (!isFinite(meanSquare)) return;
let vol = (v && !v.muted) ? v.volume : 1.0;
if (vol < 0.005) vol = 0.005;
const correctionFactor = 1.0 / vol;
const correctedMeanSquare = meanSquare * (correctionFactor * correctionFactor);
dsp.history[dsp.cursor] = correctedMeanSquare;
dsp.cursor = (dsp.cursor + 1);
if (dsp.cursor >= dsp.historySize) dsp.cursor = 0;
state.currentMomentaryLUFS = calculateLoudnessFromCircular(dsp.history, dsp.historySize, dsp.cursor, dsp.momBlocks, false);
let targetBlocks = state.cachedStats.isMusic ? dsp.shortBlocksMusic : dsp.shortBlocks;
if (state.usePseudoStable) targetBlocks = dsp.pseudoBlocks;
state.currentShortLUFS = calculateLoudnessFromCircular(dsp.history, dsp.historySize, dsp.cursor, targetBlocks, true);
state.currentIntegLUFS = calculateLoudnessFromCircular(dsp.history, dsp.historySize, dsp.cursor, dsp.historySize, true);
}
function initAudio(optionalVideo) {
if (state.isBypassed) return;
if (state.forceIgnoreNative) enforceNativeOffNow();
const v = optionalVideo || findTargetVideo();
if (!v) return;
// ★Fix: latencyHint: 'playback' は維持。音飛び防止に効果的。
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'playback' });
if (audioCtx.state === 'suspended') audioCtx.resume();
// ★FIX: Avoid full reset if video element is the same (handles quality changes gracefully)
// Only reset logic if it's a completely different video element or URL changed significantly
// (Ignoring blob: URLs often used for media segments)
const isNewElement = (v !== currentTargetVideo);
if (isNewElement || (v.src !== state.lastVideoSrc && !v.src.startsWith('blob:'))) {
console.log('[Normalizer] New video context detected. Init.');
// State Reset
state.isNavigating = false;
state.currentGain = 1.0;
state.zeroDataCount = 0;
state.recoveryAttempts = 0;
state.playingSilenceCount = 0;
state.lastVideoTime = 0;
state.isRecovering = false;
state.isBypassed = false;
state.cachedStats = { diff: null, isMusic: false, isLive: false, isNorm: false };
state.isConfirmedLive = false;
state.stickyStats = null;
scanLock = false;
const fs = audioCtx ? audioCtx.sampleRate : 48000;
const integBlocks = Math.ceil(CONFIG.WINDOW_SECONDS_INTEGRATED * fs / dsp.bufferSize);
dsp.historySize = integBlocks;
dsp.history = new Float32Array(integBlocks).fill(0);
dsp.cursor = 0;
dsp.momBlocks = Math.ceil(CONFIG.WINDOW_SECONDS_MOMENTARY * fs / dsp.bufferSize);
dsp.shortBlocks = Math.ceil(CONFIG.WINDOW_SECONDS_SHORT * fs / dsp.bufferSize);
dsp.shortBlocksMusic = Math.ceil(CONFIG.WINDOW_SECONDS_SHORT_MUSIC * fs / dsp.bufferSize);
dsp.pseudoBlocks = Math.ceil(CONFIG.WINDOW_SECONDS_PSEUDO_STABLE * fs / dsp.bufferSize);
dsp.integBlocks = integBlocks;
state.currentMomentaryLUFS = -100;
state.currentShortLUFS = -100;
state.currentIntegLUFS = -100;
// Note: cleanupAudioNodes preserves source ref if possible, so we call it here to clear filters
cleanupAudioNodes();
updateIndicator('init', 1.0, -99, CONFIG.TARGET_LUFS, {isOn:true, source:'Init'});
}
currentTargetVideo = v;
state.lastVideoSrc = v.src;
if (!hookedVideos.has(v)) ensureCrossorigin(v);
try {
// ★FIX: Check cache before creating MediaElementSource to prevent InvalidStateError
if (!nodes.source || nodes.source.mediaElement !== v) {
if (sourceCache.has(v)) {
nodes.source = sourceCache.get(v);
} else {
try {
nodes.source = audioCtx.createMediaElementSource(v);
sourceCache.set(v, nodes.source);
} catch(e) {
// Fail silently or try to recover if already connected
console.warn('MediaElementSource creation error, possibly already connected:', e);
// Attempt to reuse if nodes.source exists but we weren't sure
if (!nodes.source) return;
}
}
}
if (nodes.source && !nodes.processor) {
// Filters
nodes.kShelf = audioCtx.createBiquadFilter(); nodes.kShelf.type = "highshelf"; nodes.kShelf.frequency.value = 1500; nodes.kShelf.gain.value = 4.0;
nodes.kHighPass = audioCtx.createBiquadFilter(); nodes.kHighPass.type = "highpass"; nodes.kHighPass.frequency.value = CONFIG.LOW_CUT_FREQ;
// Analyzer
nodes.processor = audioCtx.createScriptProcessor(dsp.bufferSize, 2, 1);
nodes.processor.onaudioprocess = e => processAudioBlock(e.inputBuffer);
nodes.muteGain = audioCtx.createGain(); nodes.muteGain.gain.value = 0;
// Connections
nodes.source.connect(nodes.kShelf); nodes.kShelf.connect(nodes.kHighPass);
nodes.kHighPass.connect(nodes.processor); nodes.processor.connect(nodes.muteGain); nodes.muteGain.connect(audioCtx.destination);
// Audio Path
nodes.gain = audioCtx.createGain(); nodes.gain.gain.value = state.currentGain;
// DynEQ (ISO 226)
nodes.dynSub = audioCtx.createBiquadFilter(); nodes.dynSub.type = "lowshelf"; nodes.dynSub.frequency.value = CONFIG.DYN_EQ_SUBBASS_FREQ;
nodes.dynLow = audioCtx.createBiquadFilter(); nodes.dynLow.type = "lowshelf"; nodes.dynLow.frequency.value = CONFIG.DYN_EQ_BASS_FREQ;
nodes.dynMidLow = audioCtx.createBiquadFilter(); nodes.dynMidLow.type = "peaking"; nodes.dynMidLow.frequency.value = CONFIG.DYN_EQ_MIDBASS_FREQ; nodes.dynMidLow.Q.value = CONFIG.DYN_EQ_MIDBASS_Q;
nodes.dynHigh = audioCtx.createBiquadFilter(); nodes.dynHigh.type = "highshelf"; nodes.dynHigh.frequency.value = CONFIG.DYN_EQ_TREBLE_FREQ;
// Limiter
nodes.limiter = audioCtx.createDynamicsCompressor(); nodes.limiter.threshold.value = -1.0; nodes.limiter.ratio.value = 20.0; nodes.limiter.attack.value = 0.002;
nodes.source.connect(nodes.gain); nodes.gain.connect(nodes.dynSub); nodes.dynSub.connect(nodes.dynLow); nodes.dynLow.connect(nodes.dynMidLow); nodes.dynMidLow.connect(nodes.dynHigh); nodes.dynHigh.connect(nodes.limiter); nodes.limiter.connect(audioCtx.destination);
}
if (!state.animationId) processLoop();
} catch (e) {
console.error(e);
enableBypassMode('Init Error');
}
}
function processLoop() {
const v = currentTargetVideo;
if (!v) {
if (state.animationId && (isWatchPage() || findTargetVideo())) { requestAnimationFrame(processLoop); return; }
if (state.animationId) cancelAnimationFrame(state.animationId);
return;
}
if (state.isNavigating && !isWatchPage()) { state.animationId = requestAnimationFrame(processLoop); return; }
const now = Date.now();
if (state.forceIgnoreNative && now - state.lastNativeEnforceTime > 1000) { enforceNativeOffNow(); state.lastNativeEnforceTime = now; }
let silenceDuration = 0;
if (v && !v.paused && !state.isRecovering) {
const currentTime = v.currentTime;
if (currentTime > state.lastVideoTime + 0.05) {
if (state.currentShortLUFS === -100) {
if (state.continuousSilenceStart === 0) state.continuousSilenceStart = now;
silenceDuration = now - state.continuousSilenceStart;
} else {
state.continuousSilenceStart = 0;
state.lastValidLUFS = state.currentShortLUFS;
}
}
state.lastVideoTime = currentTime;
}
if (state.isBypassed) {
updateIndicator('failed', 1.0, -100, CONFIG.TARGET_LUFS, state.nativeStateInfo);
state.animationId = requestAnimationFrame(processLoop);
return;
}
if (!audioCtx) { state.animationId = requestAnimationFrame(processLoop); return; }
if (!state.isConfirmedLive) {
let targetId = null;
if (location.pathname.includes('/shorts/')) {
const ar = document.querySelector('ytd-reel-video-renderer[is-active]');
if (ar) targetId = ar.getAttribute('data-video-id');
} else targetId = new URLSearchParams(window.location.search).get('v');
if(!targetId) { try { const d = win.document.getElementById('movie_player')?.getVideoData(); if(d) targetId = d.video_id; } catch(e){} }
if (targetId && checkLiveRobustly(targetId)) state.isConfirmedLive = true;
}
const st = getYouTubeStats();
const diff = st ? st.diff : null;
let mode = 'waiting-silence';
let gain = 1.0;
let dispLUFS = -100;
const currentTargetLUFS = (st && st.isMusic) ? CONFIG.TARGET_LUFS_MUSIC : CONFIG.TARGET_LUFS;
let effTgt = currentTargetLUFS;
const nativeCheck = checkNativeStableVolume(st.isMusic === true);
state.nativeStateInfo = nativeCheck;
if (diff !== null && state.isConfirmedLive && v && v.duration !== Infinity && !isNaN(v.duration)) state.isConfirmedLive = false;
// --- Dynamic EQ (FIXED) ---
if (state.useDynamicEQ && nodes.dynSub && !state.isRecovering) {
let bs=0, bl=0, bm=0, bh=0;
const currentRef = state.currentShortLUFS;
if (currentRef > -100) {
// FIX: Initialize deficit to 0.
// If the volume is at the AGC target (Ref), we shouldn't boost just because the original file was quiet.
let deficit = 0;
let vol = v.volume; if (vol < 0.001) vol = 0.001;
let refVol = state.dynEqRefVolume; if (refVol < 0.01) refVol = 0.01;
// Attenuation: negative if vol > refVol
let att = -20 * safeLog10(vol / refVol);
// FIX: Allow negative attenuation (de-boost) but cap it at the slider range limit
att = Math.min(att, CONFIG.VOL_SLIDER_DYNAMIC_RANGE);
deficit += att;
if (deficit > 0) {
const f = Math.min(deficit / 40.0, 1.0);
bs = f * CONFIG.DYN_EQ_MAX_SUBBASS;
bl = f * CONFIG.DYN_EQ_MAX_BASS;
bm = f * CONFIG.DYN_EQ_MAX_MIDBASS;
bh = f * CONFIG.DYN_EQ_MAX_TREBLE;
}
}
nodes.dynSub.gain.setTargetAtTime(bs, audioCtx.currentTime, 0.2);
nodes.dynLow.gain.setTargetAtTime(bl, audioCtx.currentTime, 0.2);
nodes.dynMidLow.gain.setTargetAtTime(bm, audioCtx.currentTime, 0.2);
nodes.dynHigh.gain.setTargetAtTime(bh, audioCtx.currentTime, 0.2);
} else if (nodes.dynLow) {
nodes.dynSub.gain.setTargetAtTime(0, audioCtx.currentTime, 0.2);
nodes.dynLow.gain.setTargetAtTime(0, audioCtx.currentTime, 0.2);
nodes.dynMidLow.gain.setTargetAtTime(0, audioCtx.currentTime, 0.2);
nodes.dynHigh.gain.setTargetAtTime(0, audioCtx.currentTime, 0.2);
}
// --- AGC / Static ---
if (state.forceAGC || state.isConfirmedLive || st.isLive || diff === null) {
let hL = -100;
const mL = state.currentMomentaryLUFS, sL = state.currentShortLUFS, iL = state.currentIntegLUFS;
if (mL > -100 && sL > -100) hL = (mL > sL) ? (mL * 0.6 + sL * 0.4) : (mL * 0.1 + sL * 0.9);
else if (sL > -100) hL = sL; else if (iL > -100) hL = iL;
dispLUFS = hL;
effTgt = currentTargetLUFS + CONFIG.AGC_OFFSET;
if (state.isRecovering) mode = 'recovering';
else if (isFinite(dispLUFS) && dispLUFS > CONFIG.SILENCE_THRESHOLD) {
mode = (state.isConfirmedLive || st.isLive) ? 'agc-live' : 'agc';
gain = Math.pow(10, (effTgt - dispLUFS)/20);
} else if (silenceDuration > 0 && silenceDuration < CONFIG.AGC_HOLD_TIME && state.lastValidLUFS > CONFIG.SILENCE_THRESHOLD) {
mode = 'agc-hold';
gain = Math.pow(10, (effTgt - state.lastValidLUFS)/20);
dispLUFS = state.lastValidLUFS;
} else {
mode = 'waiting-silence';
if (!v.seeking && v.networkState !== 2 && silenceDuration > CONFIG.RECOVERY_THRESHOLD) performRecovery(false);
}
} else if (diff !== null) {
const effectiveDiff = (diff > 0) ? 0 : diff;
if (nativeCheck.isOn) {
mode = 'native-bypass-setting';
dispLUFS = -14.0 + effectiveDiff;
gain = 1.0;
} else {
mode = 'static';
gain = Math.pow(10, -effectiveDiff / 20);
dispLUFS = -14.0 + effectiveDiff;
}
}
if (mode === 'recovering' || mode === 'bypassed') gain = 1.0;
gain = Math.min(gain, CONFIG.MAX_BOOST);
if (mode.startsWith('static') || mode.startsWith('native')) {
state.currentGain = gain;
if (nodes.gain && isFinite(state.currentGain)) nodes.gain.gain.setTargetAtTime(state.currentGain, audioCtx.currentTime, 0.01);
} else {
const speed = (gain < state.currentGain) ? CONFIG.ATTACK_SPEED : CONFIG.RELEASE_SPEED;
state.currentGain += (gain - state.currentGain) * speed;
if (nodes.gain && isFinite(state.currentGain)) nodes.gain.gain.setTargetAtTime(state.currentGain, audioCtx.currentTime, 0.05);
}
updateIndicator(mode, state.currentGain, dispLUFS, effTgt, state.nativeStateInfo);
state.animationId = requestAnimationFrame(processLoop);
}
setInterval(() => {
const active = findTargetVideo();
if (!active) { if (currentTargetVideo && !document.contains(currentTargetVideo)) fullReset(); return; }
if (active && (active !== currentTargetVideo || active.src !== state.lastVideoSrc)) initAudio(active);
updateVisibility();
}, 500);
['click','keydown','scroll'].forEach(e => document.addEventListener(e, () => { if(audioCtx && audioCtx.state==='suspended') audioCtx.resume(); }, {capture:true, passive:true}));
if (window.navigation) window.navigation.addEventListener('navigate', () => setTimeout(initAudio, 50));
window.addEventListener('load', () => { initIndicator(); initAudio(); });
})();