// ==UserScript== // @name Hydra BPM Tools // @namespace https://github.com/beatmelab/hydra-bpm-tools // @version 2.0.0 // @description Adds small HUD with VJ tools (BPM, beat visualizer, resync, tap tempo, rate multiplier and hush toggle). // @author @alt234vj | @beatmelab | https://www.beatmelab.com // @license GPL-3.0 // @homepageURL https://github.com/beatmelab/hydra-bpm-tools // @supportURL https://github.com/beatmelab/hydra-bpm-tools/issues // @downloadURL https://raw.githubusercontent.com/beatmelab/hydra-bpm-tools/main/hydra-bpm-tools.user.js // @updateURL https://raw.githubusercontent.com/beatmelab/hydra-bpm-tools/main/hydra-bpm-tools.user.js // @match https://hydra.ojack.xyz/* // @grant none // @run-at document-end // ==/UserScript== /* This userscript includes adapted functionality from `geikha/hyper-hydra`. Source: https://github.com/geikha/hyper-hydra Original license: GPL-3.0 */ (function () { "use strict"; const CONFIG = { defaultBpm: 120, defaultRateMultiplier: 1, bpmStep: 1, minBpm: 1, maxBpm: 999, minRateMultiplier: 1 / 32, maxRateMultiplier: 32, resyncKey: "KeyR", resyncCtrl: true, resyncShift: true, resyncAlt: false, resyncMeta: false, hushKey: "KeyB", hushCtrl: true, hushShift: true, hushAlt: false, hushMeta: false, tapKey: "KeyT", tapCtrl: true, tapShift: true, tapAlt: false, tapMeta: false, hudToggleKey: "KeyJ", hudToggleCtrl: true, hudToggleShift: true, hudToggleAlt: false, hudToggleMeta: false, hudStorageKey: "hydra-userscript-hud-pos", hudVisibleKey: "hydra-userscript-hud-visible", sessionBpmKey: "hydra-userscript-session-bpm", sessionRateKey: "hydra-userscript-session-rate", defaultHudX: 12, defaultHudY: 12, viewportPadding: 8, tapTimeoutMs: 2000, tapHistorySize: 6, tapApplyAlpha: 0.25, tapMaxDeltaBpm: 3 }; let hud = null; let bpmWrap = null; let bpmEl = null; let rateBadgeEl = null; let hushButton = null; let beatWrap = null; let beatCells = []; let initialBpmApplied = false; let lastBeatIndex = -1; let flashTimeout = null; let isHushed = false; let beatLoopStarted = false; let tapTimes = []; // ───────────────────────────────────────────────────────────────────────────── // Utility functions // ───────────────────────────────────────────────────────────────────────────── function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } const ALLOWED_RATES = [1 / 32, 1 / 16, 1 / 8, 1 / 4, 1 / 2, 1, 2, 4, 8, 16, 32]; function roundRateMultiplier(value) { return ALLOWED_RATES.reduce((nearest, candidate) => Math.abs(value - candidate) < Math.abs(value - nearest) ? candidate : nearest ); } function median(values) { if (!values.length) return null; const sorted = [...values].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; } // ───────────────────────────────────────────────────────────────────────────── // Storage (sessionStorage for BPM/rate, localStorage for HUD position) // ───────────────────────────────────────────────────────────────────────────── function loadFromStorage(storage, key, parser, validator, fallback) { try { const raw = storage.getItem(key); if (raw !== null) { const val = parser(raw); if (validator(val)) return val; } } catch (e) {} return fallback; } function saveToStorage(storage, key, value) { try { storage.setItem(key, typeof value === "object" ? JSON.stringify(value) : String(value)); } catch (e) {} } function loadSessionBpm() { return loadFromStorage( sessionStorage, CONFIG.sessionBpmKey, (v) => clamp(Math.round(Number(v)), CONFIG.minBpm, CONFIG.maxBpm), Number.isFinite, null ); } function loadSessionRateMultiplier() { return loadFromStorage( sessionStorage, CONFIG.sessionRateKey, (v) => clamp(roundRateMultiplier(Number(v)), CONFIG.minRateMultiplier, CONFIG.maxRateMultiplier), Number.isFinite, null ); } function saveSessionBpm(value = baseBpm) { saveToStorage(sessionStorage, CONFIG.sessionBpmKey, clamp(Math.round(value), CONFIG.minBpm, CONFIG.maxBpm)); } function saveSessionRateMultiplier(value = rateMultiplier) { saveToStorage(sessionStorage, CONFIG.sessionRateKey, clamp(roundRateMultiplier(value), CONFIG.minRateMultiplier, CONFIG.maxRateMultiplier)); } function cameFromHydraPage() { try { if (!document.referrer) return false; const ref = new URL(document.referrer); return ref.origin === window.location.origin && ref.hostname === "hydra.ojack.xyz"; } catch (e) { return false; } } function initializeSessionState() { if (cameFromHydraPage()) { baseBpm = loadSessionBpm() ?? CONFIG.defaultBpm; rateMultiplier = loadSessionRateMultiplier() ?? CONFIG.defaultRateMultiplier; } else { baseBpm = CONFIG.defaultBpm; rateMultiplier = CONFIG.defaultRateMultiplier; saveSessionBpm(baseBpm); saveSessionRateMultiplier(rateMultiplier); } } let baseBpm = CONFIG.defaultBpm; let rateMultiplier = CONFIG.defaultRateMultiplier; function getCodeMirrorInstance() { const el = document.querySelector(".CodeMirror"); return el?.CodeMirror ?? null; } function getCurrentSketchCode() { return getCodeMirrorInstance()?.getValue() ?? ""; } // ───────────────────────────────────────────────────────────────────────────── // Hydra integration // ───────────────────────────────────────────────────────────────────────────── function getHydraObj() { return window.hydraSynth?.synth ?? window.hydraSynth ?? null; } function getHydraTime() { return window.hydraSynth?.synth?.time ?? window.hydraSynth?.time ?? null; } function getCurrentBaseBpm() { return baseBpm; } function getCurrentEffectiveBpm() { return clamp(Math.round(baseBpm * rateMultiplier), CONFIG.minBpm, CONFIG.maxBpm); } function getRateBadgeText() { if (rateMultiplier === 1) return ""; return rateMultiplier > 1 ? `x${rateMultiplier}` : `/${1 / rateMultiplier}`; } // ───────────────────────────────────────────────────────────────────────────── // Beat helper API (adapted from `hydra-tap.js` in `geikha/hyper-hydra`) // ───────────────────────────────────────────────────────────────────────────── function getHydraBpmValue() { const bpm = Number(window.bpm); if (Number.isFinite(bpm) && bpm > 0) return bpm; return getCurrentEffectiveBpm(); } function getNormalizedBeatProgress(divisor = 1) { const time = getHydraTime(); const bpm = getHydraBpmValue(); if (!Number.isFinite(time) || !Number.isFinite(bpm) || bpm <= 0) { return 0; } const beatLength = 60 / bpm; const cycleLength = beatLength * divisor; if (!Number.isFinite(cycleLength) || cycleLength <= 0) { return 0; } return ((time % cycleLength) + cycleLength) % cycleLength / cycleLength; } function applyRangeToFunction(fn) { fn.range = (min = 0, max = 1) => { const ranged = () => fn() * (max - min) + min; return applyCurveToFunction(applyRangeToFunction(ranged)); }; return fn; } function applyCurveToFunction(fn) { fn.curve = (q = 1) => { const curved = () => { const value = clamp(fn(), 0, 1); return q > 0 ? Math.pow(value, q) : 1 - Math.pow(1 - value, -q); }; return applyRangeToFunction(applyCurveToFunction(curved)); }; return fn; } function createBeatFunction(fn) { return applyCurveToFunction(applyRangeToFunction(fn)); } function beatsRampDown(divisor = 1) { return getNormalizedBeatProgress(divisor); } function beatsRampUp(divisor = 1) { return 1 - getNormalizedBeatProgress(divisor); } function beatsTriRampDown(divisor = 1) { const progress = getNormalizedBeatProgress(divisor * 2); return progress >= 0.5 ? getNormalizedBeatProgress(divisor) : 1 - getNormalizedBeatProgress(divisor); } function beatsTriRampUp(divisor = 1) { const progress = getNormalizedBeatProgress(divisor * 2); return progress >= 0.5 ? 1 - getNormalizedBeatProgress(divisor) : getNormalizedBeatProgress(divisor); } function installBeatHelpers() { window.range = (fn) => applyRangeToFunction(fn); window.curve = (fn) => applyCurveToFunction(fn); window.beats = (n = 1) => createBeatFunction(() => beatsRampDown(n)); window.beats_ = (n = 1) => createBeatFunction(() => beatsRampUp(n)); window.beatsTri = (n = 1) => createBeatFunction(() => beatsTriRampDown(n)); window.beatsTri_ = (n = 1) => createBeatFunction(() => beatsTriRampUp(n)); } // ───────────────────────────────────────────────────────────────────────────── // HUD (position, visibility, rendering) // ───────────────────────────────────────────────────────────────────────────── const defaultHudPos = { x: CONFIG.defaultHudX, y: CONFIG.defaultHudY }; function loadHudPosition() { return loadFromStorage( localStorage, CONFIG.hudStorageKey, JSON.parse, (p) => typeof p?.x === "number" && typeof p?.y === "number", defaultHudPos ); } function saveHudPosition(x, y) { saveToStorage(localStorage, CONFIG.hudStorageKey, { x, y }); } function isHudVisible() { return loadFromStorage(localStorage, CONFIG.hudVisibleKey, (v) => v, (v) => v !== null, "true") === "true"; } function applyHudVisibility() { if (!hud) return; const visible = isHudVisible(); hud.style.opacity = visible ? "1" : "0"; hud.style.pointerEvents = visible ? "auto" : "none"; hud.style.visibility = visible ? "visible" : "hidden"; } function toggleHudVisibility() { const visible = isHudVisible(); saveToStorage(localStorage, CONFIG.hudVisibleKey, !visible ? "true" : "false"); applyHudVisibility(); console.log("[Hydra userscript] HUD", !visible ? "shown" : "hidden"); } function applyHushButtonState() { if (!hushButton) return; hushButton.style.opacity = isHushed ? "0.92" : "1"; hushButton.style.color = isHushed ? "rgba(255,255,255,0.92)" : "rgba(255,255,255,0.86)"; hushButton.innerHTML = isHushed ? ` ` : ` `; } function keepHudInBounds() { if (!hud || !document.contains(hud)) return; if (!isHudVisible()) return; const pad = CONFIG.viewportPadding; const maxX = Math.max(pad, window.innerWidth - hud.offsetWidth - pad); const maxY = Math.max(pad, window.innerHeight - hud.offsetHeight - pad); const currentX = hud.offsetLeft; const currentY = hud.offsetTop; const nextX = clamp(currentX, pad, maxX); const nextY = clamp(currentY, pad, maxY); if (nextX !== currentX || nextY !== currentY) { hud.style.left = `${nextX}px`; hud.style.top = `${nextY}px`; saveHudPosition(nextX, nextY); } } function ensureHud() { if (hud && document.contains(hud)) { applyHudVisibility(); keepHudInBounds(); return hud; } const pos = loadHudPosition(); hud = document.createElement("div"); hud.id = "hydra-bpm-hud"; hud.style.position = "fixed"; hud.style.left = `${pos.x}px`; hud.style.top = `${pos.y}px`; hud.style.zIndex = "2147483647"; hud.style.display = "flex"; hud.style.alignItems = "center"; hud.style.gap = "8px"; hud.style.padding = "6px 8px"; hud.style.background = "rgba(0,0,0,0.9)"; hud.style.color = "#fff"; hud.style.fontFamily = "monospace"; hud.style.fontSize = "13px"; hud.style.lineHeight = "1"; hud.style.border = "1px solid rgba(255,255,255,0.18)"; hud.style.borderRadius = "8px"; hud.style.boxShadow = "0 6px 18px rgba(0,0,0,0.35)"; hud.style.userSelect = "none"; hud.style.pointerEvents = "auto"; hud.style.cursor = "move"; beatWrap = document.createElement("div"); beatWrap.style.width = "20px"; beatWrap.style.height = "20px"; beatWrap.style.display = "grid"; beatWrap.style.gridTemplateColumns = "1fr 1fr"; beatWrap.style.gridTemplateRows = "1fr 1fr"; beatWrap.style.gap = "1px"; beatWrap.style.padding = "1px"; beatWrap.style.border = "1px solid rgba(255,255,255,0.55)"; beatWrap.style.borderRadius = "2px"; beatWrap.style.background = "rgba(255,255,255,0.03)"; beatWrap.style.boxSizing = "border-box"; beatWrap.style.flex = "0 0 auto"; beatCells = []; for (let i = 0; i < 4; i++) { const cell = document.createElement("div"); cell.style.background = "rgba(255,255,255,0.12)"; cell.style.transition = "background 80ms linear, opacity 80ms linear"; cell.style.opacity = "0.45"; beatWrap.appendChild(cell); beatCells.push(cell); } bpmWrap = document.createElement("div"); bpmWrap.style.display = "flex"; bpmWrap.style.alignItems = "center"; bpmWrap.style.gap = "6px"; bpmWrap.style.minWidth = "72px"; bpmWrap.style.whiteSpace = "nowrap"; bpmEl = document.createElement("div"); bpmEl.style.fontWeight = "bold"; bpmEl.textContent = `BPM ${getCurrentBaseBpm()}`; rateBadgeEl = document.createElement("div"); rateBadgeEl.style.display = "none"; rateBadgeEl.style.fontFamily = "monospace"; rateBadgeEl.style.fontSize = "13px"; rateBadgeEl.style.fontWeight = "bold"; rateBadgeEl.style.lineHeight = "1"; rateBadgeEl.style.color = "rgb(255, 0, 0)"; rateBadgeEl.style.background = "transparent"; rateBadgeEl.style.border = "none"; rateBadgeEl.style.padding = "0"; rateBadgeEl.style.margin = "0"; rateBadgeEl.textContent = ""; bpmWrap.appendChild(bpmEl); bpmWrap.appendChild(rateBadgeEl); hushButton = document.createElement("button"); hushButton.type = "button"; hushButton.title = "Hush / Unhush"; hushButton.setAttribute("aria-label", "Hush / Unhush"); hushButton.style.width = "18px"; hushButton.style.height = "18px"; hushButton.style.padding = "0"; hushButton.style.display = "flex"; hushButton.style.alignItems = "center"; hushButton.style.justifyContent = "center"; hushButton.style.border = "none"; hushButton.style.outline = "none"; hushButton.style.background = "transparent"; hushButton.style.boxShadow = "none"; hushButton.style.cursor = "pointer"; hushButton.style.pointerEvents = "auto"; hushButton.style.flex = "0 0 auto"; hushButton.style.color = "rgba(255,255,255,0.86)"; hushButton.style.appearance = "none"; hushButton.style.webkitAppearance = "none"; hushButton.addEventListener("mouseenter", () => { hushButton.style.color = "rgba(255,255,255,0.98)"; }); hushButton.addEventListener("mouseleave", () => { applyHushButtonState(); }); hushButton.addEventListener("click", (e) => { e.stopPropagation(); toggleHush(); }); hud.appendChild(beatWrap); hud.appendChild(bpmWrap); hud.appendChild(hushButton); const appendTarget = document.body || document.documentElement; appendTarget.appendChild(hud); makeHudDraggable(hud); applyHudVisibility(); applyHushButtonState(); renderHud(); keepHudInBounds(); return hud; } function makeHudDraggable(panel) { let dragging = false; let startX = 0; let startY = 0; let panelX = 0; let panelY = 0; panel.addEventListener("mousedown", (e) => { if (e.target.closest("button")) return; dragging = true; startX = e.clientX; startY = e.clientY; panelX = panel.offsetLeft; panelY = panel.offsetTop; e.preventDefault(); }); window.addEventListener("mousemove", (e) => { if (!dragging) return; const pad = CONFIG.viewportPadding; const nextX = clamp( panelX + (e.clientX - startX), pad, Math.max(pad, window.innerWidth - panel.offsetWidth - pad) ); const nextY = clamp( panelY + (e.clientY - startY), pad, Math.max(pad, window.innerHeight - panel.offsetHeight - pad) ); panel.style.left = `${nextX}px`; panel.style.top = `${nextY}px`; }); window.addEventListener("mouseup", () => { if (!dragging) return; dragging = false; keepHudInBounds(); saveHudPosition(panel.offsetLeft, panel.offsetTop); }); } function applyEffectiveBpm() { window.bpm = getCurrentEffectiveBpm(); } function renderHud() { ensureHud(); bpmEl.textContent = `BPM ${getCurrentBaseBpm()}`; const badgeText = getRateBadgeText(); if (badgeText) { rateBadgeEl.textContent = badgeText; rateBadgeEl.style.display = "block"; } else { rateBadgeEl.textContent = ""; rateBadgeEl.style.display = "none"; } } function flashBpmText() { if (!bpmWrap) return; bpmWrap.animate( [ { opacity: 1, transform: "scale(1)", textShadow: "0 0 0 rgba(255,255,255,0)" }, { opacity: 1, transform: "scale(1.06)", textShadow: "0 0 8px rgba(255,255,255,0.95)" }, { opacity: 1, transform: "scale(1)", textShadow: "0 0 0 rgba(255,255,255,0)" } ], { duration: 140, easing: "ease-out" } ); } function setBaseBpm(value) { baseBpm = clamp(Math.round(value), CONFIG.minBpm, CONFIG.maxBpm); applyEffectiveBpm(); saveSessionBpm(baseBpm); renderHud(); console.log("[Hydra userscript] bpm base =", baseBpm, "| effective =", window.bpm); } function setRateMultiplier(value) { rateMultiplier = clamp( roundRateMultiplier(value), CONFIG.minRateMultiplier, CONFIG.maxRateMultiplier ); applyEffectiveBpm(); saveSessionRateMultiplier(rateMultiplier); renderHud(); flashBpmText(); console.log("[Hydra userscript] rate =", rateMultiplier, "| effective =", window.bpm); } function halveRateMultiplier() { setRateMultiplier(rateMultiplier / 2); } function doubleRateMultiplier() { setRateMultiplier(rateMultiplier * 2); } function registerTapTempo() { const now = performance.now(); const lastTap = tapTimes[tapTimes.length - 1]; if (lastTap && now - lastTap > CONFIG.tapTimeoutMs) { tapTimes = []; } tapTimes.push(now); if (tapTimes.length > CONFIG.tapHistorySize) { tapTimes.shift(); } flashBpmText(); if (tapTimes.length < 2) return; const intervals = []; for (let i = 1; i < tapTimes.length; i++) { intervals.push(tapTimes[i] - tapTimes[i - 1]); } if (intervals.length < 2) return; const candidateInterval = median(intervals); if (!candidateInterval || candidateInterval <= 0) return; const candidateBpm = 60000 / candidateInterval; const currentBpm = getCurrentBaseBpm(); const maxInterval = Math.max(...intervals); const minInterval = Math.min(...intervals); const spreadMs = maxInterval - minInterval; const consistent = spreadMs < 60; const difference = Math.abs(candidateBpm - currentBpm); if (consistent && intervals.length >= 3 && difference >= 8) { setBaseBpm(candidateBpm); return; } const smoothedBpm = currentBpm + (candidateBpm - currentBpm) * CONFIG.tapApplyAlpha; const delta = clamp( smoothedBpm - currentBpm, -CONFIG.tapMaxDeltaBpm, CONFIG.tapMaxDeltaBpm ); setBaseBpm(currentBpm + delta); } function renderBeatVisualizer() { ensureHud(); const bpm = getCurrentEffectiveBpm(); const t = getHydraTime(); if (t === null || !Number.isFinite(t) || bpm <= 0) { beatCells.forEach((cell) => { cell.style.background = "rgba(255,255,255,0.12)"; cell.style.opacity = "0.45"; }); return; } const totalBeats = (t * bpm) / 60; const beatIndex = Math.floor(totalBeats); const clockwiseOrder = [1, 3, 2, 0]; const activeIndex = clockwiseOrder[((beatIndex % 4) + 4) % 4]; beatCells.forEach((cell, i) => { const active = i === activeIndex; cell.style.background = active ? "rgba(255,255,255,0.98)" : "rgba(255,255,255,0.12)"; cell.style.opacity = active ? "1" : "0.45"; }); if (beatIndex !== lastBeatIndex) { lastBeatIndex = beatIndex; beatWrap.animate( [ { transform: "scale(1)" }, { transform: "scale(1.05)" }, { transform: "scale(1)" } ], { duration: 110, easing: "ease-out" } ); } // Border pulse on first beat of every 16-beat cycle (stays for full beat) const isFirstBeatOf16 = beatIndex % 16 === 0; beatWrap.style.boxShadow = isFirstBeatOf16 ? "0 0 0 1px rgba(255,255,255,0.95), 0 0 12px rgba(255,255,255,0.8)" : "none"; } function startBeatLoop() { if (beatLoopStarted) return; beatLoopStarted = true; const tick = () => { renderBeatVisualizer(); requestAnimationFrame(tick); }; requestAnimationFrame(tick); } function flashBeatVisualizer() { if (!beatWrap) return; beatWrap.style.boxShadow = "0 0 0 2px rgba(255,255,255,0.95), 0 0 10px rgba(255,255,255,0.85)"; clearTimeout(flashTimeout); flashTimeout = setTimeout(() => { // Don't clear if we're on first beat of 16-beat cycle if (beatWrap && lastBeatIndex % 16 !== 0) { beatWrap.style.boxShadow = "none"; } }, 180); } function forceInitialBpmOnce() { if (initialBpmApplied) return; if (window.hydraSynth || typeof window.bpm === "number") { initializeSessionState(); applyEffectiveBpm(); saveSessionBpm(baseBpm); saveSessionRateMultiplier(rateMultiplier); initialBpmApplied = true; renderHud(); console.log("[Hydra userscript] initial bpm base =", baseBpm, "| rate =", rateMultiplier, "| effective =", window.bpm); } } function setHushed(value) { isHushed = !!value; applyHushButtonState(); } // ───────────────────────────────────────────────────────────────────────────── // Hush / Unhush // ───────────────────────────────────────────────────────────────────────────── async function evaluateCurrentSketch() { const code = getCurrentSketchCode(); if (!code.trim()) return; try { await window.eval(`(async () => {\n${code}\n})()`); } catch (err) { console.error("[Hydra userscript] eval error", err); } } function callHush() { const hushFn = getHydraObj()?.hush ?? window.hush; if (typeof hushFn === "function") { hushFn(); return true; } return false; } async function hushNow() { setHushed(true); await evaluateCurrentSketch(); callHush(); } async function unhushNow() { setHushed(false); await evaluateCurrentSketch(); } function toggleHush() { void (isHushed ? unhushNow() : hushNow()); } function resync() { const target = window.hydraSynth?.synth ?? window.hydraSynth; if (!target) { console.warn("[Hydra userscript] Hydra instance not found"); return; } target.time = 0; flashBeatVisualizer(); lastBeatIndex = -1; console.log("[Hydra userscript] time = 0"); } // ───────────────────────────────────────────────────────────────────────────── // Keyboard shortcuts // ───────────────────────────────────────────────────────────────────────────── function matchShortcut(e, key, ctrl = false, shift = false, alt = false, meta = false) { return e.code === key && e.ctrlKey === ctrl && e.shiftKey === shift && e.altKey === alt && e.metaKey === meta; } function isCtrlShift(e, code) { return matchShortcut(e, code, true, true, false, false); } function handleShortcut(e, action) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation?.(); action(); } function onKeyDown(e) { forceInitialBpmOnce(); // Ctrl+Shift+Enter: run sketch (clear hush state) if (isCtrlShift(e, "Enter")) { setHushed(false); return; } // Ctrl+Shift+T: tap tempo if (matchShortcut(e, CONFIG.tapKey, CONFIG.tapCtrl, CONFIG.tapShift, CONFIG.tapAlt, CONFIG.tapMeta)) { return handleShortcut(e, registerTapTempo); } // Ctrl+Shift+J: toggle HUD visibility if (matchShortcut(e, CONFIG.hudToggleKey, CONFIG.hudToggleCtrl, CONFIG.hudToggleShift, CONFIG.hudToggleAlt, CONFIG.hudToggleMeta)) { return handleShortcut(e, toggleHudVisibility); } // Ctrl+Shift+B: toggle hush if (matchShortcut(e, CONFIG.hushKey, CONFIG.hushCtrl, CONFIG.hushShift, CONFIG.hushAlt, CONFIG.hushMeta)) { return handleShortcut(e, toggleHush); } // Ctrl+Shift+R: resync if (matchShortcut(e, CONFIG.resyncKey, CONFIG.resyncCtrl, CONFIG.resyncShift, CONFIG.resyncAlt, CONFIG.resyncMeta)) { return handleShortcut(e, resync); } // Ctrl+Shift+Arrows: BPM and rate control if (isCtrlShift(e, "ArrowLeft")) return handleShortcut(e, halveRateMultiplier); if (isCtrlShift(e, "ArrowRight")) return handleShortcut(e, doubleRateMultiplier); if (isCtrlShift(e, "ArrowUp")) return handleShortcut(e, () => setBaseBpm(baseBpm + CONFIG.bpmStep)); if (isCtrlShift(e, "ArrowDown")) return handleShortcut(e, () => setBaseBpm(baseBpm - CONFIG.bpmStep)); } // ───────────────────────────────────────────────────────────────────────────── // Initialization // ───────────────────────────────────────────────────────────────────────────── function waitForHydraThenInitBpm() { let tries = 0; const timer = setInterval(() => { if (++tries >= 200 || initialBpmApplied) { clearInterval(timer); return; } forceInitialBpmOnce(); }, 100); } function install() { installBeatHelpers(); ensureHud(); renderHud(); applyHudVisibility(); applyHushButtonState(); keepHudInBounds(); startBeatLoop(); waitForHydraThenInitBpm(); window.addEventListener("keydown", onKeyDown, true); window.addEventListener("beforeunload", () => { saveSessionBpm(baseBpm); saveSessionRateMultiplier(rateMultiplier); }); window.addEventListener("pagehide", () => { saveSessionBpm(baseBpm); saveSessionRateMultiplier(rateMultiplier); }); window.addEventListener("resize", () => { keepHudInBounds(); }); console.log("[Hydra userscript] installed"); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", install, { once: true }); } else { install(); } })();