diff --git a/lib/routers/settings.py b/lib/routers/settings.py index 853533b1..b480c93c 100644 --- a/lib/routers/settings.py +++ b/lib/routers/settings.py @@ -139,6 +139,15 @@ def save_settings(data: dict): except (TypeError, ValueError, OverflowError): return {"error": "av_offset_ms must be a number between -1000 and 1000"} + if "av_offset_manual" in data: + # Whether av_offset_ms was hand-set by the user. When true the client + # protects the value from auto-calibrate overwrites across reloads. + raw = data["av_offset_manual"] + if raw is not None: + if not isinstance(raw, bool): + return {"error": "av_offset_manual must be a boolean"} + updates["av_offset_manual"] = raw + # fee[dB]ack v0.3.0 gameplay settings (tabbed settings page). null is a # no-op per the merge contract; bad shapes return a structured error # rather than 500. countdown_before_song is consumed by the song-start @@ -362,7 +371,7 @@ def save_settings(data: dict): # known set means a malformed or hostile body can't wipe unrelated config. _RESETTABLE_SETTINGS_KEYS = frozenset({ "default_arrangement", "demucs_server_url", "master_difficulty", - "av_offset_ms", "countdown_before_song", "miss_penalty", "fail_behavior", + "av_offset_ms", "av_offset_manual", "countdown_before_song", "miss_penalty", "fail_behavior", "reference_pitch", "instrument", "string_count", "tuning", "pathway", "instrument_profiles", "active_instrument_profile", "achievements_enabled", "use_amp_sims", diff --git a/static/js/settings.js b/static/js/settings.js index a195d2f1..ec9a3233 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -142,7 +142,10 @@ export async function loadSettings() { // render clock, the Settings slider, the HUD readout, and the // module variable all pick it up consistently. Pass skipPersist // so we don't echo the loaded value back to the server. - setAvOffsetMs(Number(data.av_offset_ms) || 0, /* skipPersist */ true); + setAvOffsetMs(Number(data.av_offset_ms) || 0, /* skipPersist */ true, 'load'); + // Restore the hand-set protection so a value dialed in a prior session + // keeps auto-calibrate from overwriting it after reload. + _avUserOverride = !!data.av_offset_manual; // Arrangement naming mode is localStorage-only (client preference). const namingModeEl = document.getElementById('arrangement-naming-mode'); if (namingModeEl) namingModeEl.value = _getArrangementNamingMode(); @@ -585,13 +588,23 @@ export function handleSliderInput(el) { // setAvOffsetMs without saving (skipPersist=true) to avoid an // echo-back round-trip. export let _avOffsetMs = 0; +// Sticky once the user dials A/V in by hand this session. An auto-calibrate +// sweep (note_detect) or any other programmatic caller must NOT overwrite a +// hand-set value — that's the "I set it and it's wrong again" bug. Persisted as +// av_offset_manual so it survives a reload too. +export let _avUserOverride = false; export let _avSaveDebounce = null; -export function setAvOffsetMs(ms, skipPersist) { - // Clamp to the same bounds the Settings/player-bar sliders enforce - // (-1000..1000 ms). Defends against bad values from /api/settings - // landing as `value` on . +// `source`: 'manual' = a user control (marks the override); 'load' = seeding the +// saved value (no override); anything else (undefined — the plugin's +// window.setAvOffsetMs auto-calibrate call) is IGNORED while an override holds. +export function setAvOffsetMs(ms, skipPersist, source) { + if (source !== 'manual' && source !== 'load' && _avUserOverride) return; + if (source === 'manual') _avUserOverride = true; + // Clamp to ±1000 (the exact-entry number box may exceed the ±300 slider + // range for edge cases); the sliders themselves are ±300 for real + // resolution. Defends against bad values from /api/settings. const n = Number(ms); _avOffsetMs = Math.max(-1000, Math.min(1000, Number.isFinite(n) ? n : 0)); // Drive the highway's render-time shift. getTime() still returns @@ -613,6 +626,11 @@ export function setAvOffsetMs(ms, skipPersist) { playerAvSlider.value = _avOffsetMs; handleSliderInput(playerAvSlider); } + // Exact-entry number box — don't fight the user while they're typing in it. + const playerAvNum = document.getElementById('player-av-offset-num'); + if (playerAvNum && document.activeElement !== playerAvNum) { + playerAvNum.value = Math.round(_avOffsetMs); + } const playerAvLabel = document.getElementById('player-av-offset-label'); if (playerAvLabel) { const rounded = Math.round(_avOffsetMs); @@ -638,7 +656,9 @@ export function _persistAvOffset() { await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ av_offset_ms: _avOffsetMs }), + // Persist whether this value was hand-set so a reload keeps + // protecting it from auto-calibrate (see setAvOffsetMs / load). + body: JSON.stringify({ av_offset_ms: _avOffsetMs, av_offset_manual: _avUserOverride }), }); } catch (e) { console.warn('A/V offset save failed:', e); @@ -647,7 +667,8 @@ export function _persistAvOffset() { } export function nudgeAvOffsetMs(delta) { - setAvOffsetMs(Math.max(-1000, Math.min(1000, _avOffsetMs + delta))); + // Keyboard [ / ] is a hand adjustment → mark the manual override. + setAvOffsetMs(Math.max(-1000, Math.min(1000, _avOffsetMs + delta)), false, 'manual'); } export async function saveSettings() { diff --git a/static/v3/index.html b/static/v3/index.html index 35743074..444b164f 100644 --- a/static/v3/index.html +++ b/static/v3/index.html @@ -492,8 +492,8 @@

Gameplay Settings

Positive = audio plays ahead of visual notes; raise to catch the highway up. Adjust live with the [ and ] keys (Shift for ±50 ms). Auto-saves.
- +
@@ -1208,10 +1208,13 @@

Gameplay Settings

A/V sync (ms) - - +0ms + +
+
+ ↑ higher = notes sooner (visuals faster) · ↓ lower = later · type an exact value on the right +
Loop