Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Use it at your own risk, and back up your rekordbox library before importing any
- **DJM mixer awareness** — surfaces DJM channel/master state on the link
- **Native FLAC/WAV/AIFF playback** — CDJ decodes lossless formats directly over NFS
- **Color waveforms** on the CDJ (PWV4 overview + PWV5 scrolling, generated with time-domain Butterworth filter banks calibrated against rekordbox's rendering); honors the CDJ "waveform color" setting (blue / RGB / 3-band) in both the deck and the web UI
- **BPM detection** with autocorrelation + a perceptual tempo prior, coherence-verified integer/half-integer snap, and a DP beat tracker
- **BPM detection** with autocorrelation + a perceptual tempo prior, coherence-verified integer/half-integer snap, and a DP beat tracker. Detects **60–200 BPM**; set an **Analysis BPM range** (Settings → Library Source, or `--bpm-range 90-180`) so fast genres like drum & bass lock onto the true tempo instead of its half/third. Tracks above 200 BPM are analyzed at a sub-harmonic (usually half-tempo) — 200 is the current ceiling
- **Key detection** using chromagram analysis (octaves 4-7, Krumhansl-Kessler profiles)
- **Beat grid** generation phase-locked via a multiband onset tempogram (modelled on rekordbox's reverse-engineered 25-band novelty), windowed clarity-weighted phase, and a gated half-beat correction
- **Phrase/song structure** detection (PSSI format)
Expand Down
28 changes: 28 additions & 0 deletions analysis/analysis.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ type Result struct {
SongStructure []byte // PSSI phrase analysis blob for 0x2504 response
Phrases []Phrase // detected phrases (intro/up/down/chorus/outro) — used by API/web UI
GridEdited bool // user manually adjusted the beat grid — serve our blobs, not the on-disk ANLZ

// TempoMinBPM / TempoMaxBPM record the --bpm-range setting this result was
// analyzed under (0 = the default dance window). The cache treats a result
// as stale when they differ from the current setting, so changing the range
// re-analyzes affected tracks instead of serving BPMs from the old range.
TempoMinBPM float64
TempoMaxBPM float64
}

// gridCarry is a user-edited beat grid rescued from a stale-version cache
Expand Down Expand Up @@ -400,6 +407,16 @@ func (s *Store) Invalidate(trackID uint32) {
}
}

// InvalidateAll drops every in-memory result so the next Get for each track
// re-reads the disk cache — where the staleness check re-analyzes anything it
// now rejects (e.g. after the BPM range changed). On-disk entries are left for
// that per-track check to discard lazily; nothing is re-analyzed up front.
func (s *Store) InvalidateAll() {
s.mu.Lock()
s.results = make(map[uint32]*Result)
s.mu.Unlock()
}

// RenameCachedPath moves the on-disk analysis cache from the slot
// keyed by `oldPath` to the one keyed by `newPath` so a path remap
// doesn't force re-analysis. Silently noops when there's no cache.
Expand Down Expand Up @@ -464,6 +481,16 @@ func (s *Store) loadFromDisk(filePath string) *Result {
os.Remove(path)
return nil
}
if curMin, curMax := TempoRange(); r.TempoMinBPM != curMin || r.TempoMaxBPM != curMax {
// The BPM range changed since this was cached. The BPM and grid were fit
// under the old range, so re-analyze under the new one. Unlike a version
// bump we do not carry a user-edited grid across: an edit made at the old
// tempo is meaningless once the tempo refolds.
log.Printf("analysis-cache: tempo range changed (was %g-%g, now %g-%g), re-analyzing %s",
r.TempoMinBPM, r.TempoMaxBPM, curMin, curMax, filepath.Base(path))
os.Remove(path)
return nil
}
return &r
}

Expand Down Expand Up @@ -505,6 +532,7 @@ func AnalyzeTrack(filePath string) (*Result, error) {
DownbeatIndex: downbeatIdx,
Phrases: phrases,
}
r.TempoMinBPM, r.TempoMaxBPM = TempoRange()

// ---- encode to the installed wire format (Pioneer today) ----
waveformEncoder.Encode(samples, AnalysisRate, r, beatResult)
Expand Down
89 changes: 87 additions & 2 deletions analysis/bpm.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
)

// BeatResult holds the detected BPM and actual beat positions.
Expand All @@ -29,12 +30,87 @@ const (
tempoPriorSigma = 0.5
)

// defaultTempoLo / defaultTempoHi bound the candidate BPMs the detector will
// accept by default — the window a sub-/super-harmonic has to fall inside to be
// chosen. They match the historical hard-coded 80-170 range and are only
// widened/narrowed when a caller sets an explicit tempo range (TempoMinBPM /
// TempoMaxBPM).
const (
defaultTempoLo = 80.0
defaultTempoHi = 170.0
)

// The opt-in tempo range (set from --bpm-range or the web Analysis Setting).
// When both bounds are set (max > min > 0) the detector only accepts tempo
// candidates inside [min, max] and re-centres the perceptual prior on that
// range. This forces genres the default dance window gets wrong — most notably
// drum & bass, which otherwise locks onto its half (~87) or third (~58) tempo
// because the real ~174 is above the default 170 ceiling. Zero (the default)
// leaves the historical behaviour untouched. Guarded by a mutex because the web
// settings handler can change it at runtime while analysis workers read it.
// Changing it changes analysis output, so AnalyzeTrack records the range in each
// Result and the cache treats a mismatch as stale (see analysis.go).
var (
tempoRangeMu sync.RWMutex
tempoMinBPM float64
tempoMaxBPM float64
)

// SetTempoRange sets the opt-in BPM detection range. (0, 0) — or any max <= min
// — clears it, restoring the default window. Safe to call at runtime.
func SetTempoRange(min, max float64) {
tempoRangeMu.Lock()
defer tempoRangeMu.Unlock()
tempoMinBPM, tempoMaxBPM = min, max
}

// TempoRange returns the configured range (0, 0 when unset).
func TempoRange() (min, max float64) {
tempoRangeMu.RLock()
defer tempoRangeMu.RUnlock()
return tempoMinBPM, tempoMaxBPM
}

// tempoRangeSet reports whether a usable explicit tempo range is configured.
func tempoRangeSet() bool {
min, max := TempoRange()
return min > 0 && max > min
}

// tempoWindow returns the inclusive BPM range a candidate must fall within to
// be accepted — the configured range when set, else the default dance window.
func tempoWindow() (lo, hi float64) {
min, max := TempoRange()
if min > 0 && max > min {
return min, max
}
return defaultTempoLo, defaultTempoHi
}

// tempoPriorParams returns the log-Gaussian prior's centre and sigma (octaves).
// With an explicit range the centre is the range's geometric mean and sigma is
// half its width in octaves (floored so a narrow range stays usefully tight),
// so the prior peaks in the middle of the user's range instead of at 130.
func tempoPriorParams() (center, sigma float64) {
min, max := TempoRange()
if min > 0 && max > min {
center = math.Sqrt(min * max)
sigma = math.Log2(max/min) / 2
if sigma < 0.05 {
sigma = 0.05
}
return center, sigma
}
return tempoPriorCenter, tempoPriorSigma
}

// tempoPrior weights a candidate BPM by perceptual likelihood. Without it,
// autocorrelation alone often locks onto a sub-/super-harmonic (most commonly
// the 2/3 sub-harmonic — a 124 BPM track also peaks at ~82.7); the prior pulls
// the choice back to the musically-correct tempo.
func tempoPrior(bpm float64) float64 {
x := math.Log2(bpm/tempoPriorCenter) / tempoPriorSigma
center, sigma := tempoPriorParams()
x := math.Log2(bpm/center) / sigma
return math.Exp(-0.5 * x * x)
}

Expand Down Expand Up @@ -241,6 +317,9 @@ func DetectBeatsWithEncoderDelay(samples []float32, sampleRate int, encoderDelay

// Use autocorrelation on onset to find the dominant periodicity.
// This is more robust than raw median for tracks with off-beat hits.
// The 60-200 BPM search span is the detector's hard range (also bounded by
// minSpacingFrames above); the web Analysis-Setting presets and custom-range
// clamp mirror this 60-200 window — keep them in step if it ever changes.
minLag := int(framesPerSec * 60.0 / 200.0)
maxLag := int(framesPerSec * 60.0 / 60.0)
if maxLag >= numFrames/2 {
Expand Down Expand Up @@ -291,11 +370,17 @@ func DetectBeatsWithEncoderDelay(samples []float32, sampleRate int, encoderDelay
// autocorrelation support.
ratios := []float64{1, 0.5, 2, 1.0 / 3, 3, 0.75, 4.0 / 3, 1.5, 2.0 / 3}

// Accept candidates within the configured tempo window (default 80-170).
// The ratio list already covers the half/third harmonics, so an explicit
// fast range (e.g. 160-185 for DnB) makes the real tempo the only ratio
// that lands in-window and lets it win instead of its half.
tempoLo, tempoHi := tempoWindow()

bpm := rawBPM
bestCandScore := 0.0
for _, r := range ratios {
c := rawBPM * r
if c < 80 || c > 170 {
if c < tempoLo || c > tempoHi {
continue
}
// Check autocorrelation support at this candidate's lag.
Expand Down
82 changes: 82 additions & 0 deletions analysis/bpm_range_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package analysis

import (
"math"
"testing"
)

// withTempoRange sets the package tempo-range config for the duration of fn and
// restores it after, so tests don't leak state into each other.
func withTempoRange(lo, hi float64, fn func()) {
om, oM := TempoRange()
SetTempoRange(lo, hi)
defer SetTempoRange(om, oM)
fn()
}

// TestTempoWindowAndPriorDefaults: with no range set, the window and prior keep
// their historical values so nothing changes for existing users.
func TestTempoWindowAndPriorDefaults(t *testing.T) {
withTempoRange(0, 0, func() {
if lo, hi := tempoWindow(); lo != defaultTempoLo || hi != defaultTempoHi {
t.Errorf("default window = %g-%g, want %g-%g", lo, hi, defaultTempoLo, defaultTempoHi)
}
if c, s := tempoPriorParams(); c != tempoPriorCenter || s != tempoPriorSigma {
t.Errorf("default prior = center %g sigma %g, want %g/%g", c, s, tempoPriorCenter, tempoPriorSigma)
}
})
}

// TestTempoWindowAndPriorConfigured: an explicit range becomes the window and
// re-centres the prior on the range's geometric mean.
func TestTempoWindowAndPriorConfigured(t *testing.T) {
withTempoRange(160, 185, func() {
lo, hi := tempoWindow()
if lo != 160 || hi != 185 {
t.Errorf("window = %g-%g, want 160-185", lo, hi)
}
c, s := tempoPriorParams()
wantCenter := math.Sqrt(160 * 185)
if math.Abs(c-wantCenter) > 0.01 {
t.Errorf("prior center = %g, want %g (geometric mean)", c, wantCenter)
}
if s <= 0 || s > 0.5 {
t.Errorf("prior sigma = %g, want a tight positive value", s)
}
})
}

// TestTempoPriorFavoursRange: inside a DnB range the prior must prefer the true
// tempo (174) over its half (87) — the opposite of the default-130 prior.
func TestTempoPriorFavoursRange(t *testing.T) {
withTempoRange(160, 185, func() {
if tempoPrior(174) <= tempoPrior(87) {
t.Errorf("prior(174)=%.4f should exceed prior(87)=%.4f under a 160-185 range",
tempoPrior(174), tempoPrior(87))
}
})
}

// TestBPMRangeForcesTrueTempo is the end-to-end proof. A clean 174 BPM kick
// train is exactly the reporter's case: with the default 80-170 window the real
// 174 is above the ceiling, so detection folds it to the half (~87); set a
// 160-185 range and it comes back at ~174.
func TestBPMRangeForcesTrueTempo(t *testing.T) {
samples := synthKickTrain(174, 90)

withTempoRange(0, 0, func() {
got := DetectBeatsWithEncoderDelay(samples, AnalysisRate, 0).BPM
if got < 84 || got > 90 {
t.Errorf("default: 174 track detected %.2f, expected the ~87 half-tempo fold", got)
}
})

withTempoRange(160, 185, func() {
got := DetectBeatsWithEncoderDelay(samples, AnalysisRate, 0).BPM
if got < 160 || got > 185 {
t.Errorf("with range 160-185: 174 track detected %.2f, want it inside the range (~174)", got)
}
})
}
29 changes: 28 additions & 1 deletion api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -3609,6 +3609,25 @@ func parseTrackIDFromPath(path, prefix string) uint32 {
return id
}

// syncBPMRange applies the persisted Analysis-Setting BPM range to the analyzer
// and, when it actually changed, drops cached analyses so affected tracks
// re-analyze on next access (lazy — nothing is re-analyzed up front). Called
// after a settings write.
func (s *Server) syncBPMRange() {
if s.Device == nil || s.Device.Settings == nil {
return
}
mn, mx := s.Device.Settings.GetBPMRange()
if oldMn, oldMx := analysis.TempoRange(); mn == oldMn && mx == oldMx {
return
}
analysis.SetTempoRange(mn, mx)
if s.Analysis != nil {
s.Analysis.InvalidateAll()
}
log.Printf("api: BPM analysis range -> %g-%g; cached analyses invalidated", mn, mx)
}

// handleSettings handles GET/POST /api/settings for CDJ display settings.
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
Expand Down Expand Up @@ -3671,12 +3690,19 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
// Backward-compatible: existing 5-field DEVSETTING-derived shape,
// plus the full YAML config under "full" so the web UI can render
// every MYSETTING/MYSETTING2/DJMMYSETTING field too.
full := settings.Config()
// Report the EFFECTIVE analysis BPM range, not just what's persisted:
// --bpm-range overrides the range for the session without writing
// settings.json, so reflect it here or the UI shows Default while a
// flag-set range is actually in force. Editing it in the UI still
// persists to settings.json as usual.
full.BPMRangeMin, full.BPMRangeMax = analysis.TempoRange()
resp := struct {
SettingsJSON
Full device.SettingsConfig `json:"full"`
}{
SettingsJSON: devSettingToJSON(),
Full: settings.Config(),
Full: full,
}
writeJSON(w, resp)
return
Expand All @@ -3703,6 +3729,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
if combined.Full != nil {
settings.SetConfig(*combined.Full)
log.Printf("api: settings: full config replaced")
s.syncBPMRange() // keep the analyzer + cache in step with the Analysis Setting
s.Device.NotifySettingsChanged()
writeJSON(w, struct{ OK bool }{true})
return
Expand Down
Loading
Loading