// MusicRoom - ReplayGain module // Applies per-track loudness normalization using server-provided rsgain metadata. // Gain is applied client-side via the shared Web Audio gain node (M.gainNode), // so it only affects this listener's playback. Preferences are persisted // per-account via /api/auth/me/preferences (localStorage fallback). (function() { const M = window.MusicRoom; const LS_ENABLED = "blastoise_replaygain_enabled"; const LS_PREAMP = "blastoise_replaygain_preamp"; const PREAMP_MIN = -12; const PREAMP_MAX = 12; const PREAMP_STEP = 0.5; const MAX_LINEAR = 10; // +20 dB hard cap to avoid extreme boosts M.replayGain = { enabled: localStorage.getItem(LS_ENABLED) !== "false", // default true preampDb: clampPreamp(Number.parseFloat(localStorage.getItem(LS_PREAMP)) || 0), }; M.currentTrack = null; function clampPreamp(v) { if (!Number.isFinite(v)) return 0; return Math.max(PREAMP_MIN, Math.min(PREAMP_MAX, Math.round(v / PREAMP_STEP) * PREAMP_STEP)); } // linear gain factor for a track, honoring enable flag, preamp, and peak clipping function computeLinearGain(track) { if (!M.replayGain.enabled) return 1.0; const preamp = M.replayGain.preampDb || 0; let gainDb = preamp; const rgDb = track ? track.replayGainDb : null; if (Number.isFinite(rgDb)) gainDb += rgDb; let linear = Math.pow(10, gainDb / 20); const peak = track ? track.replayPeak : null; if (Number.isFinite(peak) && peak > 0 && peak * linear > 1) { linear = 1 / peak; } return Math.min(linear, MAX_LINEAR); } function setGain(node, linear) { const ctx = node.context; if (ctx.state === "running") { const t = ctx.currentTime; node.gain.cancelScheduledValues(t); node.gain.setTargetAtTime(linear, t, 0.02); // ~20ms ramp, avoids clicks } else { node.gain.value = linear; // context suspended; apply directly } } // Apply gain for the current (or given) track. Safe to call before the graph // exists — the value is re-applied when the graph initializes. M.applyReplayGain = function(track) { if (track) M.currentTrack = track; const t = M.currentTrack; const hasData = Number.isFinite(t && t.replayGainDb) || Number.isFinite(t && t.replayPeak); // Only build the audio graph when there's actually something to apply (or it // already exists), preserving default behavior for libraries without RG. if (M.replayGain.enabled && (hasData || M.gainNode || M.replayGain.preampDb)) { M.ensureAudioGraph && M.ensureAudioGraph(); } const node = M.gainNode; if (!node) return; setGain(node, computeLinearGain(t)); }; M.setReplayGainEnabled = function(enabled) { M.replayGain.enabled = !!enabled; localStorage.setItem(LS_ENABLED, M.replayGain.enabled ? "true" : "false"); M.applyReplayGain(); M.updateReplayGainUI && M.updateReplayGainUI(); M.saveReplayGainPrefs && M.saveReplayGainPrefs(); }; M.setReplayGainPreamp = function(db) { M.replayGain.preampDb = clampPreamp(db); localStorage.setItem(LS_PREAMP, String(M.replayGain.preampDb)); M.applyReplayGain(); M.updateReplayGainUI && M.updateReplayGainUI(); M.saveReplayGainPrefs && M.saveReplayGainPrefs(); }; // Debounced persistence to the server (per-account). localStorage is the // immediate fallback for guests/offline. let saveTimer = null; M.saveReplayGainPrefs = function() { if (saveTimer) clearTimeout(saveTimer); saveTimer = setTimeout(() => { saveTimer = null; const body = { replaygain_enabled: M.replayGain.enabled ? "true" : "false", replaygain_preamp: String(M.replayGain.preampDb), }; fetch("/api/auth/me/preferences", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }).catch(() => {}); }, 400); }; // Hydrate from the /api/auth/me preferences object (called by auth.js). M.loadReplayGainPrefs = function(prefs) { if (!prefs) return; if (prefs.replaygain_enabled != null) { M.replayGain.enabled = prefs.replaygain_enabled === "true"; localStorage.setItem(LS_ENABLED, M.replayGain.enabled ? "true" : "false"); } if (prefs.replaygain_preamp != null) { const v = Number.parseFloat(prefs.replaygain_preamp); if (Number.isFinite(v)) { M.replayGain.preampDb = clampPreamp(v); localStorage.setItem(LS_PREAMP, String(M.replayGain.preampDb)); } } M.applyReplayGain(); M.updateReplayGainUI && M.updateReplayGainUI(); }; // ---- UI ---- function initReplayGainUI() { const btn = M.$("#btn-replaygain"); const popover = M.$("#replaygain-popover"); const enabledCheckbox = M.$("#rg-enabled"); const preampSlider = M.$("#rg-preamp"); const preampValue = M.$("#rg-preamp-value"); if (!btn) return; function open() { if (!popover) return; popover.classList.remove("hidden"); document.addEventListener("pointerdown", onOutside, true); document.addEventListener("keydown", onKey); } function close() { if (!popover) return; popover.classList.add("hidden"); document.removeEventListener("pointerdown", onOutside, true); document.removeEventListener("keydown", onKey); } function toggle() { if (popover && popover.classList.contains("hidden")) open(); else close(); } function onOutside(e) { if (popover && !popover.contains(e.target) && e.target !== btn) close(); } function onKey(e) { if (e.key === "Escape") close(); } btn.onclick = (e) => { e.stopPropagation(); toggle(); }; if (enabledCheckbox) { enabledCheckbox.onchange = () => M.setReplayGainEnabled(enabledCheckbox.checked); } if (preampSlider) { preampSlider.min = String(PREAMP_MIN); preampSlider.max = String(PREAMP_MAX); preampSlider.step = String(PREAMP_STEP); preampSlider.oninput = () => M.setReplayGainPreamp(Number.parseFloat(preampSlider.value)); } M.updateReplayGainUI = function() { if (btn) btn.classList.toggle("active", M.replayGain.enabled); if (enabledCheckbox) enabledCheckbox.checked = M.replayGain.enabled; if (preampSlider) preampSlider.value = String(M.replayGain.preampDb); if (preampValue) { const db = M.replayGain.preampDb; preampValue.textContent = (db > 0 ? "+" : "") + (Number.isInteger(db) ? db : db.toFixed(1)) + " dB"; } }; M.updateReplayGainUI(); } document.addEventListener("DOMContentLoaded", initReplayGainUI); })();