YouTube Volume Normalizer

YouTubeの音量を基準値(-14 LUFS)に統一します。EBU R 128/BS.1770準拠のK特性フィルタとゲーティング計算を実装し、より自然で歪みのない音量調整を実現しました。

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         YouTube Volume Normalizer
// @namespace    http://tampermonkey.net/
// @version      3.3
// @description  YouTubeの音量を基準値(-14 LUFS)に統一します。EBU R 128/BS.1770準拠のK特性フィルタとゲーティング計算を実装し、より自然で歪みのない音量調整を実現しました。
// @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
// @grant        GM_registerMenuCommand
// @run-at       document-start
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const win = unsafeWindow || window;

    // ==========================================
    // ■ 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)
    // ==========================================
    const loudnessDB = new Map();
    const metaDB = new Map();
    const MAX_CACHE_SIZE = 500;

    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 (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 (!obj || typeof obj !== 'object' || depth > 50) return;
        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);
        }
        if (Array.isArray(obj)) {
            for (let i = 0; i < obj.length; i++) scanDeep(obj[i], depth + 1);
            return;
        }
        for (const key in obj) {
            if (['formats','adaptiveFormats','dashManifest','hlsManifest','storyboard','trackingParams','adPlacements','attestation','thumbnails'].includes(key)) continue;
            if (key === 'reelWatchEndpoint' || key === 'playerResponse') scanDeep(obj[key], 0);
            else scanDeep(obj[key], depth + 1);
        }
    }

    const originalJsonParse = win.JSON.parse;
    win.JSON.parse = function() {
        const data = originalJsonParse.apply(this, arguments);
        try { if (data && typeof data === 'object') setTimeout(() => scanDeep(data), 0); } catch (e) {}
        return data;
    };

    let _ytInitialPlayerResponse = win.ytInitialPlayerResponse;
    Object.defineProperty(win, 'ytInitialPlayerResponse', {
        get: () => _ytInitialPlayerResponse,
        set: (val) => { _ytInitialPlayerResponse = val; setTimeout(() => scanDeep(val), 0); },
        configurable: true, enumerable: true
    });

    const originalResponseJson = win.Response.prototype.json;
    win.Response.prototype.json = async function() {
        const data = await originalResponseJson.apply(this, arguments);
        setTimeout(() => scanDeep(data), 0);
        return data;
    };

    setTimeout(() => {
        if (win.ytInitialPlayerResponse) scanDeep(win.ytInitialPlayerResponse);
        if (win.ytInitialData) scanDeep(win.ytInitialData);
    }, 500);

    // ==========================================
    // ■ Observer & Play Hook
    // ==========================================
    let lastPlayTime = 0;

    function ensureCrossorigin(video) {
        if (video.getAttribute('crossorigin') !== 'anonymous') {
            originalSetAttribute.call(video, 'crossorigin', 'anonymous');
        }
        if (video.crossOrigin !== 'anonymous') {
            video.crossOrigin = 'anonymous';
        }
        if (!video._norm_hooked) {
            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 (!isWatchPage()) return;
        if (v !== currentTargetVideo || !nodes.source) {
            initAudio(v);
        }
    }

    function seekToLiveEdge(v) {
        try {
            const player = win.document.getElementById('movie_player');
            if (player && player.seekToStreamTime && player.getDuration) {
                player.seekTo(player.getDuration(), true);
                return true;
            }
            if (v.duration === Infinity && v.buffered && v.buffered.length > 0) {
                const end = v.buffered.end(v.buffered.length - 1);
                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: -20.0,
        AGC_OFFSET: 0.0,
        WINDOW_SECONDS_SHORT: 3.0,
        WINDOW_SECONDS_INTEGRATED: 60.0,

        MAX_BOOST: 15.0,
        ATTACK_SPEED: 0.1,
        RELEASE_SPEED: 0.05,
        
        // 【修正】パワースペクトル密度(PSD)の誤差補正
        // BS.1770標準の約38Hzまで下げることで、低域のエネルギー密度を正確に計算に含め、
        // 聴感上の音量が大きい(PSDが大きい)場合に数値を正しく反映させる。
        LOW_CUT_FREQ: 38, // 元: 100Hz -> 修正: 38Hz (ITU-R BS.1770-4準拠)

        SILENCE_LIMIT_BLOCKS: 60,
        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,

        HYBRID_BLEND: 0.4
    };

    const UI = {
        TOP: '60px', RIGHT: '10px',
        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 };
    let currentTargetVideo = null;
    let learnedNativeState = GM_getValue('learnedNativeState', null);

    let state = {
        lastVideoSrc: '',
        currentShortLUFS: -100,
        currentIntegLUFS: -100,
        currentGain: 1.0,
        animationId: null,
        showIndicator: GM_getValue('showIndicator', true),
        forceIgnoreNative: GM_getValue('forceIgnoreNative', false),
        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
    };

    let dsp = {
        history: null, historySize: 0, cursor: 0,
        bufferSize: 2048
    };

    let uiElement = null;

    // ==========================================
    // 2. Logic & Detection
    // ==========================================
    function safeLog10(val) {
        return (val > 1e-12) ? Math.log10(val) : -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' };
        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 }
            ];
            const targetKey = 'yt-player-stable-volume';
            for (const s of storages) {
                if (!s.store) continue;
                const raw = s.store.getItem(targetKey);
                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);
        }
    }, { 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) 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/')) {
            targetId = location.pathname.split('/shorts/')[1]?.split('?')[0];
        } else {
            const params = new URLSearchParams(window.location.search);
            targetId = params.get('v');
        }

        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
    // ==========================================
    GM_registerMenuCommand(`インジケーター表示切替`, () => {
        state.showIndicator = !state.showIndicator;
        GM_setValue('showIndicator', state.showIndicator);
        state.showIndicator ? initIndicator() : removeIndicator();
    });

    GM_registerMenuCommand(`設定: 「一定音量」を強制的にOFFとみなす (Toggle)`, () => {
        state.forceIgnoreNative = !state.forceIgnoreNative;
        GM_setValue('forceIgnoreNative', state.forceIgnoreNative);
        alert(state.forceIgnoreNative ? "【強制ON】Stableモードを有効化します" : "【自動検出】Youtube設定に従います");
    });

    function initIndicator() {
        if (!state.showIndicator) return;
        if (document.getElementById('yt-norm-indicator')) { updateVisibility(); return; }
        const el = document.createElement('div');
        el.id = 'yt-norm-indicator';
        el.style.cssText = `position: fixed !important; top: ${UI.TOP} !important; right: ${UI.RIGHT} !important; width: 8px !important; height: 8px !important; border-radius: 50% !important; background-color: #888; z-index: 2147483647 !important; opacity: 0.6 !important; pointer-events: auto !important; cursor: help !important; display: none !important; transition: background-color 0.1s !important; box-shadow: 0 0 4px rgba(0,0,0,0.8) !important;`;
        el.addEventListener('mouseenter', () => { el.style.opacity = '1.0'; });
        el.addEventListener('mouseleave', () => { el.style.opacity = '0.6'; });
        document.body.appendChild(el);
        uiElement = el;
    }

    function removeIndicator() {
        const el = document.getElementById('yt-norm-indicator');
        if (el) el.remove();
        uiElement = null;
    }

    function updateVisibility() {
        if (!uiElement) return;
        const isTarget = isWatchPage();
        uiElement.style.display = (state.showIndicator && isTarget) ? 'block' : 'none';
    }

    function updateIndicator(status, gain, inputLUFS, effectiveTarget, nativeInfo) {
        if (!state.showIndicator) return;

        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] ハイブリッド自動調整`; break;
            case 'waiting-loading': color = UI.COLORS.WAIT; titleHead = `[待機] 読み込み中`; break;
            case 'native-bypass-setting': color = UI.COLORS.NATIVE; titleHead = `[バイパス] YouTube機能で制御`; break;
            case 'native-bypass-loud': color = UI.COLORS.NATIVE; titleHead = `[バイパス] 音量過多`; 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();
        const statusChanged = (state.lastStatusColor !== color);
        if (!statusChanged && (now - state.lastUiUpdate < CONFIG.UI_UPDATE_INTERVAL)) {
            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 * Math.log10(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' || status === 'agc-silence') && state.lastValidLUFS > -100) {
            lufsDisplay = `${state.lastValidLUFS.toFixed(1)} (直前)`;
        }

        let nativeStateStr = nativeInfo.isOn ? "ON (有効)" : "OFF (無効)";
        const nativeStr = `YouTube "一定音量": ${nativeStateStr}\n[判定ソース: ${nativeInfo.source}]`;

        let body = "";
        if (status.startsWith('agc') || status === 'waiting-silence') {
            let subText = "";
            if (status === 'agc-hold') subText = "(無音区間: ゲイン維持中)";
            else if (status === 'waiting-silence') subText = "(データ待機中)";
            else if (status === 'agc-live') subText = "(ライブ配信)";

            const shortStr = state.currentShortLUFS > -100 ? state.currentShortLUFS.toFixed(1) : "-";
            const integStr = state.currentIntegLUFS > -100 ? state.currentIntegLUFS.toFixed(1) : "-";
            body = `合成入力: ${lufsDisplay}\n(Short: ${shortStr} / Integ: ${integStr})\n補正: ${gainText}\n${subText}`;
        }
        if (status === 'static') body = `音量が小さいため補正しています\n補正量: ${gainText}`;

        if (uiElement.style.backgroundColor !== color) uiElement.style.backgroundColor = color;
        const newTitle = `${titleHead}\n----------------\n${body}\n----------------\n目標: ${effectiveTarget.toFixed(1)} LUFS${state.cachedStats.isMusic ? ' (Music)' : ''}\n${nativeStr}`;
        if (uiElement.title !== newTitle) uiElement.title = newTitle;
    }

    // ==========================================
    // 5. Processing & Recovery
    // ==========================================

    function onNavigateStart() {
        console.log('[Normalizer] Navigate Start -> Muting Gain...');
        state.isNavigating = true;
        if (nodes.gain && audioCtx) {
            try {
                const now = audioCtx.currentTime;
                nodes.gain.gain.cancelScheduledValues(now);
                nodes.gain.gain.setValueAtTime(nodes.gain.gain.value, now);
                nodes.gain.gain.linearRampToValueAtTime(0, now + 0.1);
            } catch(e) {}
        }
    }

    function onNavigateFinish() {
        if (!isWatchPage()) {
            fullReset();
        }
    }

    function fullReset() {
        console.log('[Normalizer] Full Reset (Non-watch page)');
        state.isNavigating = false;
        currentTargetVideo = null;
        state.lastVideoSrc = '';
        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.playingSilenceCount = 0;
        state.lastVideoTime = 0;
        state.isConfirmedLive = false;
        state.isBypassed = false;
        state.stickyStats = 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;
        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] Hard Recovery Attempt (${state.recoveryAttempts})...`);
        if (audioCtx) { try { await audioCtx.close(); } catch(e){} audioCtx = null; }
        cleanupAudioNodes();
        nodes = { source: null, gain: null, limiter: null, processor: null, kShelf: null, kHighPass: null };

        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) {
                clearInterval(watchdog);
                state.zeroDataCount = 0;
                state.isRecovering = false;
                initAudio();
            }
        }, 1000);
        setTimeout(() => {
            if (state.isRecovering) {
                clearInterval(watchdog);
                state.isRecovering = false;
                initAudio();
            }
        }, 8000);
    }

    function cleanupAudioNodes() {
        if (nodes.source) { try { nodes.source.disconnect(); } catch(e){} nodes.source = null; }
        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; }
        nodes = { source: null, gain: null, limiter: null, processor: null, kShelf: null, kHighPass: null };
    }

    // ==========================================
    // Core DSP Logic (BS.1770 Compliant)
    // ==========================================
    function calculateGatedLoudness(buffer, length) {
        if (length === 0) return -100;

        const absThreshLinear = Math.pow(10, (CONFIG.ABS_SILENCE_THRESHOLD + 0.691) / 10.0);
        let sum = 0;
        let count = 0;

        for (let i = 0; i < length; i++) {
            const val = buffer[i];
            if (val > absThreshLinear) {
                sum += val;
                count++;
            }
        }

        if (count === 0) return -100;

        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(absThreshLinear, relThresholdLinear);

        let gatedSum = 0;
        let gatedCount = 0;

        for (let i = 0; i < length; i++) {
            const val = buffer[i];
            if (val > finalThreshold) {
                gatedSum += val;
                gatedCount++;
            }
        }

        if (gatedCount === 0) return -100;

        return -0.691 + 10 * safeLog10(gatedSum / gatedCount);
    }

    function processAudioBlock(inputBuffer) {
        const v = currentTargetVideo;
        if (v && v.seeking) {
            state.zeroDataCount = 0;
            return;
        }

        const ch0 = inputBuffer.getChannelData(0);

        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) {
                     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++;
                 }
                 return;
             }
        }

        state.zeroDataCount = 0;
        if (state.recoveryAttempts > 0) state.recoveryAttempts = 0;
        if (state.zeroDataCount > CONFIG.SILENCE_LIMIT_BLOCKS) return;

        const len = ch0.length;
        const numChannels = inputBuffer.numberOfChannels;
        let sumSquares = 0;

        // パワースペクトル密度(PSD)の積算:
        // 時間領域における二乗和計算は、パーセバルの定理により周波数領域でのエネルギー総和(PSD積分)と等価です。
        // ここでは適切なK特性フィルタ(Shelf+HPF)を通した信号を用いて計算することで、
        // 「PSDが大きいほど聴感上の音量が大きい」という特性をラウドネス値に反映させています。
        for (let c = 0; c < numChannels; c++) {
            const data = inputBuffer.getChannelData(c);
            for (let i = 0; i < len; i++) {
                sumSquares += data[i] * data[i];
            }
        }

        const meanSquare = sumSquares / len;

        if (!isFinite(meanSquare)) return;

        dsp.history[dsp.cursor] = meanSquare;
        dsp.cursor = (dsp.cursor + 1) % dsp.historySize;

        const fs = audioCtx.sampleRate;
        const shortTermSamples = Math.ceil(CONFIG.WINDOW_SECONDS_SHORT * fs / dsp.bufferSize);
        let shortBuffer = [];
        let ptr = dsp.cursor - 1;
        for(let i=0; i<shortTermSamples; i++) {
            if(ptr < 0) ptr = dsp.historySize - 1;
            shortBuffer.push(dsp.history[ptr]);
            ptr--;
        }
        state.currentShortLUFS = calculateGatedLoudness(shortBuffer, shortBuffer.length);
        state.currentIntegLUFS = calculateGatedLoudness(dsp.history, dsp.historySize);
    }

    function initAudio(optionalVideo) {
        if (state.isBypassed) return;
        const v = optionalVideo || findTargetVideo();
        if (!v) return;

        if (v !== currentTargetVideo || v.src !== state.lastVideoSrc) {
            console.log('[Normalizer] New video detected. Init.');
            currentTargetVideo = v;
            state.lastVideoSrc = v.src;

            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;

            const fs = audioCtx ? audioCtx.sampleRate : 48000;
            const bufferLen = Math.ceil(CONFIG.WINDOW_SECONDS_INTEGRATED * fs / dsp.bufferSize);
            dsp.historySize = bufferLen;
            dsp.history = new Float32Array(bufferLen).fill(0);
            dsp.cursor = 0;

            state.currentShortLUFS = -100;
            state.currentIntegLUFS = -100;

            cleanupAudioNodes();
            updateIndicator('init', 1.0, -99, CONFIG.TARGET_LUFS, {isOn:true, source:'Init'});
        }

        if (v.getAttribute('crossorigin') !== 'anonymous') {
            originalSetAttribute.call(v, 'crossorigin', 'anonymous');
            v.crossOrigin = 'anonymous';
        }
        if (!v._norm_hooked) {
            v._norm_hooked = true;
            v.addEventListener('play', onVideoPlay, { capture: true });
        }

        try {
            if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
            if (audioCtx.state === 'suspended') audioCtx.resume();

            const bufferLen = Math.ceil(CONFIG.WINDOW_SECONDS_INTEGRATED * audioCtx.sampleRate / dsp.bufferSize);
            if (dsp.historySize !== bufferLen) {
                 dsp.historySize = bufferLen;
                 dsp.history = new Float32Array(bufferLen).fill(0);
                 dsp.cursor = 0;
            }

            if (!nodes.source) {
                try { nodes.source = audioCtx.createMediaElementSource(v); }
                catch (e) { /* already connected */ }
            }

            if (nodes.source && !nodes.processor) {
                // 【補正】K特性フィルタ (Stage 1: High Shelf)
                nodes.kShelf = audioCtx.createBiquadFilter();
                nodes.kShelf.type = "highshelf";
                nodes.kShelf.frequency.value = 1500; // BS.1770 Stage 1近似
                nodes.kShelf.gain.value = 4.0;
                nodes.kShelf.Q.value = 0.5;

                // 【補正】K特性フィルタ (Stage 2: High Pass)
                // パワースペクトル密度(PSD)が大きい帯域(低域)を無視しないよう、
                // BS.1770の基準に合わせてカットオフを100Hzから38Hzへ緩和。
                // Q値を0.71(Butterworth近似)とし、特定の周波数のみが強調される共振誤差を防ぐ。
                nodes.kHighPass = audioCtx.createBiquadFilter();
                nodes.kHighPass.type = "highpass";
                nodes.kHighPass.frequency.value = CONFIG.LOW_CUT_FREQ; // 38Hz
                nodes.kHighPass.Q.value = 0.71; // 1.0(共振あり)から0.71(平坦)へ変更

                nodes.processor = audioCtx.createScriptProcessor(dsp.bufferSize, 2, 1);
                nodes.processor.onaudioprocess = e => {
                    processAudioBlock(e.inputBuffer);
                };

                nodes.source.connect(nodes.kShelf);
                nodes.kShelf.connect(nodes.kHighPass);
                nodes.kHighPass.connect(nodes.processor);
                nodes.processor.connect(audioCtx.destination);

                nodes.gain = audioCtx.createGain();
                nodes.limiter = audioCtx.createDynamicsCompressor();
                nodes.limiter.threshold.value = -1.0;
                nodes.limiter.ratio.value = 20;
                nodes.limiter.attack.value = 0.002;

                nodes.source.connect(nodes.gain);
                nodes.gain.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()) {
                 requestAnimationFrame(processLoop);
                 return;
            }
            if (state.animationId) cancelAnimationFrame(state.animationId);
            return;
        }

        if (state.isNavigating) {
             state.animationId = requestAnimationFrame(processLoop);
             return;
        }

        const now = Date.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/')) targetId = location.pathname.split('/shorts/')[1]?.split('?')[0];
            else targetId = new URLSearchParams(window.location.search).get('v');
            if (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) {
            if (v && v.duration !== Infinity && !isNaN(v.duration)) state.isConfirmedLive = false;
        }

        if (state.isConfirmedLive || st.isLive || diff === null) {

            let hybridLUFS = -100;
            const sL = state.currentShortLUFS;
            const iL = state.currentIntegLUFS;

            if (sL > -100 && iL > -100) {
                hybridLUFS = (sL * CONFIG.HYBRID_BLEND) + (iL * (1 - CONFIG.HYBRID_BLEND));
            } else if (sL > -100) {
                hybridLUFS = sL;
            } else if (iL > -100) {
                hybridLUFS = iL;
            }

            dispLUFS = hybridLUFS;
            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 && silenceDuration > CONFIG.RECOVERY_THRESHOLD) performRecovery(false);
            }
        }
        else if (v && (isNaN(v.duration) || v.readyState < 2)) {
             mode = 'waiting-loading';
        }
        else if (diff !== null) {
            const isQuiet = diff < 0;
            if (nativeCheck.isOn) {
                mode = 'native-bypass-setting';
                dispLUFS = -14.0 + diff;
            } else if (!isQuiet) {
                mode = 'native-bypass-loud';
                dispLUFS = -14.0 + diff;
            } else {
                mode = 'static';
                gain = Math.pow(10, -diff / 20);
                dispLUFS = -14.0 + diff;
            }
        }

        if (mode === 'recovering' || mode === 'bypassed') gain = 1.0;
        gain = Math.min(gain, CONFIG.MAX_BOOST);

        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 && !isWatchPage()) {
                 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(); });
})();