blastoise/public/replayGain.js

186 lines
6.5 KiB
JavaScript

// 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);
})();