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