186 lines
6.5 KiB
JavaScript
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);
|
|
})();
|