Skip to content

analysis: optional BPM range to fix half/third-tempo detection - #55

Open
vynulldev wants to merge 5 commits into
mainfrom
bpm-range
Open

vynulldev wants to merge 5 commits into
mainfrom
bpm-range

Conversation

@vynulldev

Copy link
Copy Markdown
Owner

What & why

Fixes #52.

Fast genres, drum & bass especially, were analyzing at half (~87) or a third (~58) of their true tempo. The cause is in the detector: a hard-coded candidate window of 80-170 BPM, reinforced by a perceptual tempo prior centered on 130. A real 174 BPM track is above the 170 ceiling, so it gets rejected and its strong half-tempo autocorrelation peak wins instead.

This adds an opt-in BPM range. When set, it replaces the candidate window and re-centres the prior on the range's geometric mean, so the true tempo becomes the only harmonic that lands in-window and wins. The default (unset) keeps the historical 80-170 window and 130 prior exactly, so nothing changes for anyone who doesn't opt in.

Two ways to set it:

  • --bpm-range 90-180 on the command line.
  • A BPM Range dropdown in the web UI (Settings → Library Source): Default (shown as its real 80-170 window) plus octave-wide presets 60-120, 70-140, 80-160, 90-180, 100-200, and a Custom option with min/max inputs.

The presets and the custom inputs are tuned to the detector's real 60-200 BPM search range (autocorrelation + peak spacing), rather than copying rekordbox's Analysis Setting list verbatim, whose 48-95 and 108-255 entries would have dead zones here. Custom values are clamped to 60-200 with a hint. Tracks above 200 BPM remain out of range (they analyze at a sub-harmonic); that's documented as the current ceiling, with a comment tying the Go bounds to the web mirror so they don't drift.

The range persists in settings.json (SettingsConfig.BPMRangeMin/Max, like TrackDetail) and is read at startup before analysis; --bpm-range overrides it for that session. Changing it in the UI applies to the analyzer live and drops cached analyses (Store.InvalidateAll) so affected tracks re-analyze as they're next loaded. Each Result records the range it was computed under, and the cache treats a mismatch as stale, so changing the range re-analyzes instead of serving BPMs fit under the old one. The analyzer's range is behind a mutex (SetTempoRange/TempoRange) because the settings handler can change it at runtime while analysis workers read it.

Out of scope for this PR (possible follow-ups): a per-batch option in the web Add Files flow for mixed-genre libraries. A global range biases non-matching tracks, which is inherent to a global setting and the reason the per-batch option is noted for later.

Hardware testing

  • Tested on: Default path is opt-in and unchanged, so existing libraries and decks see identical output. The range path is covered by tests, not yet on hardware with real tracks: a synthetic 174 BPM kick train folds to ~87 under Default and resolves to ~174 with a 160-185 range (TestBPMRangeForcesTrueTempo), the settings round-trip is unit-tested, the analyzer setter is race-tested, and the full make check including e2e passes. The live HTTP settings round-trip (GET/POST/persist/sync/invalidate) was verified against a running server. Real drum & bass tracks on a CDJ are queued for the next decks session before merge.

Checklist

  • go build ./..., go vet ./..., and go test ./... pass
  • gofmt -l . is clean
  • New source files carry an SPDX header (GPL-3.0-or-later)
  • Tested on real hardware (deck + firmware noted above), or this change doesn't affect deck behaviour
  • I agree my contribution is licensed under the project's GPLv3

@vynulldev vynulldev mentioned this pull request Oct 7, 2026
2 tasks done
@kayrozen

kayrozen commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

If i set via --bpm range, in web, it is writen as "Default".
If i set via web, it doesnt retain my choice, goes back to default. event with custom one.

@kayrozen

kayrozen commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Bug: the web BPM-range control never renders the persisted value — and every save wipes the rest of the settings

Found while testing this PR. Two related bugs, both in the new index.html code:

1. Render always shows "Default"

renderSettings() is called with two different object shapes: the poll loop passes renderSettings(settings.full) (the full config itself), while the initial load passes the API response (which has .full). The new BPM code assumes the response shape only:

const bpmFull = (cfg && cfg.full) || {};   // undefined on the poll path

So bpm_range_min/max are never read — the dropdown shows "Default" even when settings.json contains a persisted range (confirmed: settings.json had bpm_range_min: 80, bpm_range_max: 160 while the UI displayed Default).

2. Saving from the web destructively truncates the whole settings config

postBPMRange() builds its POST payload from currentSettings.full, which is also undefined on the poll path:

const full = JSON.parse(JSON.stringify((currentSettings && currentSettings.full) || {}));

The POST then sends {full: {bpm_range_min, bpm_range_max}} without the rest of the config, and since handleSettings does a full SetConfig replace + persist, every BPM change rewrites settings.json with only those two fields — wiping my_setting, my_setting_2, etc. That's the "doesn't retain my choice, goes back to default" symptom.

(Side note, design rather than bug: --bpm-range on the CLI intentionally doesn't write settings.json, so the web shows Default for a flag-set range. Now that the render bug is fixed, that's visible — worth deciding whether the flag should be reflected in the UI.)

Fix

Accept both shapes when reading, and base the POST on the full config:

1|diff --git a/api/web/index.html b/api/web/index.html
2|index 1aabd23..a34b4f6 100644
3|--- a/api/web/index.html
4|+++ b/api/web/index.html
5|@@ -7269,7 +7269,9 @@ function renderSettings(cfg) {
6| 
7|   // LIBRARY SOURCE: import from rekordbox + bulk path fix (not a CDJ setting).
8|   if (section.key === 'source') {
9|-    const bpmFull = (cfg && cfg.full) || {};
10|+    // cfg is either the API response (has .full) or the full config itself
11|+    // (1 Hz poll passes settings.full) — accept both shapes.
12|+    const bpmFull = (cfg && (cfg.full || cfg)) || {};
13|     const bpmCurMin = Number(bpmFull.bpm_range_min) || 0;
14|     const bpmCurMax = Number(bpmFull.bpm_range_max) || 0;
15|     const bpmCustomActive = bpmCurMin > 0 && bpmCurMax > bpmCurMin &&
16|@@ -7412,7 +7414,7 @@ async function handleSettingChange(select) {
17| // when the persisted range matches no preset (e.g. a custom value entered here
18| // or via --bpm-range on the CLI).
19| function bpmRangeOptionsHTML(cfg) {
20|-  const full = (cfg && cfg.full) || {};
21|+  const full = (cfg && (cfg.full || cfg)) || {};
22|   const curMin = Number(full.bpm_range_min) || 0;
23|   const curMax = Number(full.bpm_range_max) || 0;
24|   let matched = false;
25|@@ -7429,15 +7431,19 @@ function bpmRangeOptionsHTML(cfg) {
26| // postBPMRange persists a range via the full-config POST (the same path every
27| // settings field uses) and reflects it immediately.
28| async function postBPMRange(min, max, label) {
29|-  const full = JSON.parse(JSON.stringify((currentSettings && currentSettings.full) || {}));
30|+  // currentSettings is the full config itself (renderSettings receives
31|+  // settings.full in the poll loop) — base the POST on it, not .full, or the
32|+  // full-config POST would wipe every other settings field.
33|+  const full = JSON.parse(JSON.stringify(currentSettings || {}));
34|   full.bpm_range_min = min;
35|   full.bpm_range_max = max;
36|+  delete full.full; // in case currentSettings was the API response shape
37|   const r = await fetch('/api/settings', {
38|     method: 'POST', headers: {'Content-Type': 'application/json'},
39|     body: JSON.stringify({ full }),
40|   });
41|   if (!r.ok) { toast('SAVE FAILED', 'error'); return; }
42|-  if (currentSettings) { currentSettings.full = full; lastSettingsKey = ''; }
43|+  if (currentSettings) { currentSettings.bpm_range_min = min; currentSettings.bpm_range_max = max; lastSettingsKey = ''; }
44|   toast('BPM RANGE · ' + label, 'success');
45| }
46| ```

Verified locally: dropdown now shows the persisted preset, web changes survive the 1 Hz poll and a page reload, and `settings.json` keeps its other sections after a BPM save. Applied on top of the PR as a verification patch (`git apply` on the PR head, no commits pushed).

@vynulldev vynulldev added the enhancement New feature or request label Oct 8, 2026
vynulldev added a commit that referenced this pull request Oct 8, 2026
…eview)

renderSettings receives the full config itself on the 1 Hz poll
(renderSettings(settings.full)), not the API-response shape, so the BPM
code's cfg.full was always undefined: the dropdown always showed Default
even with a persisted range, and postBPMRange built its POST from
currentSettings.full (undefined) and sent {full: {only the two BPM
fields}}. Since the settings POST does a SetConfig replace, every BPM
change rewrote settings.json with just those two fields, wiping
my_setting, dev_setting, and the rest.

Accept both config shapes (x.full || x) at the read sites, and base the
POST on the whole config so the other sections are preserved. Verified
both shapes read the persisted range and the POST payload retains the
other settings.

Found with a byte-exact root-cause analysis and a verification patch in
the review of #55.

Co-authored-by: kayrozen <kayrozen@users.noreply.github.com>
vynulldev added a commit that referenced this pull request Oct 8, 2026
…-up)

--bpm-range overrides the analysis range for the session without writing
settings.json, so the settings GET (which returned only the persisted
config) showed Default while a flag-set range was actually in force. Report
the effective analysis range instead, so the UI shows a flag-set value.
Editing it in the UI still persists to settings.json as before, and the
flag stays non-persistent. Verified live: --bpm-range 90-180 now reads back
90/180 while settings.json keeps no persisted range.

Raised as a side note in the #55 review by kayrozen.
@vynulldev

Copy link
Copy Markdown
Owner Author

Great catch, and thank you for the root-cause write-up, looks good on both counts. The settings-wipe one especially, that's destructive.

Pushed the fix. I went with the symmetric form of your read fix on the write side too, basing the POST on (currentSettings.full || currentSettings) rather than deleting .full, so it stays correct even on the API-response shape. Verified both shapes read the persisted range and that a BPM save keeps every other section.

On your side note: yes, --bpm-range is deliberately a non-persistent session override, so it doesn't touch settings.json, which is why the UI showed Default for a flag-set range. Fixed that too, in a follow-up commit: the settings GET now reports the effective analysis range, so a flag-set value shows in the UI, while the flag itself stays non-persistent (editing it in the UI still writes settings.json as before). Verified --bpm-range 90-180 reads back as 90/180 with nothing persisted.

Give it a shot and let me know. Thanks again!

vynulldev added a commit that referenced this pull request Oct 8, 2026
…eview)

renderSettings receives the full config itself on the 1 Hz poll
(renderSettings(settings.full)), not the API-response shape, so the BPM
code's cfg.full was always undefined: the dropdown always showed Default
even with a persisted range, and postBPMRange built its POST from
currentSettings.full (undefined) and sent {full: {only the two BPM
fields}}. Since the settings POST does a SetConfig replace, every BPM
change rewrote settings.json with just those two fields, wiping
my_setting, dev_setting, and the rest.

Accept both config shapes (x.full || x) at the read sites, and base the
POST on the whole config so the other sections are preserved. Verified
both shapes read the persisted range and the POST payload retains the
other settings.

Found with a byte-exact root-cause analysis and a verification patch in
the review of #55.

Co-authored-by: kayrozen <kayrozen@users.noreply.github.com>
vynulldev added a commit that referenced this pull request Oct 8, 2026
…-up)

--bpm-range overrides the analysis range for the session without writing
settings.json, so the settings GET (which returned only the persisted
config) showed Default while a flag-set range was actually in force. Report
the effective analysis range instead, so the UI shows a flag-set value.
Editing it in the UI still persists to settings.json as before, and the
flag stays non-persistent. Verified live: --bpm-range 90-180 now reads back
90/180 while settings.json keeps no persisted range.

Raised as a side note in the #55 review by kayrozen.
vynulldev and others added 5 commits October 9, 2026 16:43
Fast genres like drum & bass analyze at half (~87) or a third (~58) of
their true tempo. The cause is the detector's candidate window, hard-coded
to 80-170 BPM: a real 174 is above the ceiling, so it is rejected and the
in-window half wins. A perceptual prior centred on 130 reinforces the pull.

Add an opt-in --bpm-range MIN-MAX (e.g. 160-185). When set it becomes the
candidate window and re-centres the prior on the range's geometric mean,
so the true tempo is the only harmonic that lands in-window and wins. The
default (0) keeps the historical 80-170 window and 130 prior unchanged, so
nothing changes for anyone who does not set it.

Changing the range changes analysis output, so each Result records the
range it was computed under and the cache re-analyzes a track when the
setting differs. Global setting for now; a per-batch option in the web
Add Files flow is a likely follow-up for mixed-genre libraries.

Resolves the request in issue #52.
Expose the --bpm-range setting in the web UI so it isn't startup-only. The
Library Source settings section gets a BPM Range dropdown: Default (shown
with its actual 80-170 window) plus octave-wide presets 60-120, 70-140,
80-160, 90-180, 100-200, and a Custom option with min/max inputs.

The presets and the custom inputs stay inside the beat detector's real
60-200 BPM search range (autocorrelation + peak spacing) rather than
copying rekordbox's list, whose 48-95 and 108-255 entries would have dead
zones here. Custom values are clamped to 60-200 with a hint.

The range persists in settings.json alongside the other settings
(SettingsConfig.BPMRangeMin/Max, like TrackDetail) and is read at startup
before analysis runs, with --bpm-range overriding it for that session.
Changing it in the UI applies to the analyzer live and drops cached
analyses so affected tracks re-analyze as they're next loaded (lazy).

The analyzer's tempo range is now behind a mutex (SetTempoRange/TempoRange)
since the settings handler can change it at runtime while analysis workers
read it. Adds Store.InvalidateAll for the runtime cache drop, a settings
persistence test, and verified the HTTP round-trip (GET/POST/persist/sync).
Note the configurable Analysis BPM range (web + --bpm-range) in the feature
list, and that the detector covers 60-200 BPM — tracks above 200 analyze at
a sub-harmonic (usually half-tempo), the current ceiling.
…eview)

renderSettings receives the full config itself on the 1 Hz poll
(renderSettings(settings.full)), not the API-response shape, so the BPM
code's cfg.full was always undefined: the dropdown always showed Default
even with a persisted range, and postBPMRange built its POST from
currentSettings.full (undefined) and sent {full: {only the two BPM
fields}}. Since the settings POST does a SetConfig replace, every BPM
change rewrote settings.json with just those two fields, wiping
my_setting, dev_setting, and the rest.

Accept both config shapes (x.full || x) at the read sites, and base the
POST on the whole config so the other sections are preserved. Verified
both shapes read the persisted range and the POST payload retains the
other settings.

Found with a byte-exact root-cause analysis and a verification patch in
the review of #55.

Co-authored-by: kayrozen <kayrozen@users.noreply.github.com>
…-up)

--bpm-range overrides the analysis range for the session without writing
settings.json, so the settings GET (which returned only the persisted
config) showed Default while a flag-set range was actually in force. Report
the effective analysis range instead, so the UI shows a flag-set value.
Editing it in the UI still persists to settings.json as before, and the
flag stays non-persistent. Verified live: --bpm-range 90-180 now reads back
90/180 while settings.json keeps no persisted range.

Raised as a side note in the #55 review by kayrozen.
@vynulldev
vynulldev marked this pull request as ready for review October 9, 2026 21:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

specify BPM range when analysing.

2 participants